From 0b7d8410ea541126fdc2051c555e6782fa7cdc10 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sun, 13 Sep 2026 15:54:32 +0200 Subject: [PATCH 01/22] Drop the duplicated event publisher the merge left behind Both branches added an ApplicationEventPublisher to AdminProjectService, so the automatic merge kept two identical constructor parameters and nothing compiled. Co-Authored-By: Claude Opus 5 --- .../sprintstartbackend/user/service/AdminProjectService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/AdminProjectService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/AdminProjectService.kt index 25f3dc92..4724fb72 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/AdminProjectService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/AdminProjectService.kt @@ -44,7 +44,6 @@ class AdminProjectService( private val githubRepositoryApi: GithubRepositoryApi, private val eventPublisher: ApplicationEventPublisher, private val jiraInstanceApi: JiraInstanceApi, - private val eventPublisher: ApplicationEventPublisher, ) { /** * Returns all projects with source and assigned-user summaries. From aa7735652c953649e9ead09a01e82efae6f18ef4 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sun, 13 Sep 2026 16:25:07 +0200 Subject: [PATCH 02/22] Let the buddy see the onboarding path, and walk the hire along it The mentor knew about arrival steps, pull requests, competencies and the board -- everything around the onboarding path -- and nothing about the phases, steps and knowledge questions the hire's PM actually prescribed. So "what should I do next" was answered out of the work pool while the hire sat on a page saying something else: two plans, and nothing saying which was the plan. Read side (BuddyPathTools): - `get_my_onboarding_path` gives the phase the hire is standing in in full, one named next thing, the titles of what is ahead, and any phase that came back empty. Mounted only for a hire who has a path, and the greeting's state snapshot gains the same in two sentences. - Full detail for the current phase, titles only for what is ahead: a mentor that recites sixteen phases has produced a table of contents, and the hire has one already. It also bounds the prompt. - No correct answers, because the hire-facing shape carries none -- the mentor cannot leak an answer it was never given. And no blended progress figure, following the rule the arrival tool states at length. Write side (BuddyPathActions), each a proposal the hire confirms: - `complete_step` -- asked for, never announced; only the hire knows they did it. - `answer_question` -- the hire's own answer, matched to an option server-side from the same input at propose and confirm time, so what is recorded is what they read on the button. A wrong answer is a recorded attempt, not a failed action. - `add_path_step` -- for a phase that came back empty, or something real the path does not mention. It writes to the hire's own copy; the PM's blueprint is untouched, which is the line that makes any of this safe to offer. All three sit outside the project gate, because a path belongs to a person: a hire onboarding on two projects still has exactly one. Nodes are resolved through the hire's own path, which makes the lookup the authorization check too. Co-Authored-By: Claude Opus 5 --- .../external/enums/BuddyActionType.kt | 28 + .../external/model/BuddyStreamEvent.kt | 10 + .../model/request/buddy/BuddyActionRequest.kt | 22 + .../onboarding/service/BuddyActionService.kt | 51 +- .../onboarding/service/BuddyPathActions.kt | 536 ++++++++++++++++++ .../onboarding/service/BuddyPathTools.kt | 441 ++++++++++++++ .../onboarding/service/BuddyService.kt | 7 +- .../onboarding/service/BuddyToolExecutor.kt | 10 + .../service/OnboardingPathService.kt | 30 + .../service/BuddyActionServiceTest.kt | 26 +- .../onboarding/service/BuddyPathActionTest.kt | 471 +++++++++++++++ .../onboarding/service/BuddyPathToolsTest.kt | 318 +++++++++++ .../onboarding/service/BuddyServiceTest.kt | 6 +- .../service/BuddyToolExecutorTest.kt | 11 + 14 files changed, 1957 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt index 562f683f..703e9a3f 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt @@ -40,6 +40,34 @@ enum class BuddyActionType( * skill is not something anybody should have to confirm blind. */ RECORD_ASSESSMENT("record_assessment", "Save this placement"), + + /** + * The three path actions: the mentor moving the hire along the curriculum their PM wrote. + * + * They are what turns the buddy from a second onboarding mechanism into the tutor for the first + * one. One line decides how far that goes, and it is worth stating here rather than only in the + * tool descriptions: **these touch the hire's own copy of the path, never the blueprint.** The + * curriculum belongs to the PM; a mentor that could edit it is a mentor whose team stops + * trusting it. Everything here is reversible on the hire's own page, which is what makes + * proposing them reasonable at all. + * + * Their [label]s are fallbacks. Each proposal names the actual step, the actual answer or the + * actual title, because "Confirm" over a change to somebody's onboarding is not something + * anybody should have to click blind. + */ + COMPLETE_STEP("complete_step", "Mark this step as done"), + + /** + * Sends the hire's own answer to a knowledge question. + * + * The hire's words, never the mentor's. The mentor is not told which option is correct (see + * `BuddyPathTools`), so it cannot answer for them even if it tried — and the button shows the + * answer that will be sent, because an attempt is recorded whether it is right or not. + */ + ANSWER_QUESTION("answer_question", "Send this answer"), + + /** Adds a step the conversation produced to a phase of the hire's own path. */ + ADD_PATH_STEP("add_path_step", "Add this step to your path"), ; companion object { diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt index d21b70b9..69f0d922 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt @@ -42,4 +42,14 @@ data class BuddyStreamEvent( /** `record_assessment` confirm payload: which competency, and the level in words. */ @SerialName("competency_key") val competencyKey: String? = null, val level: String? = null, + /** + * Path-action confirm payloads: which node of the hire's own onboarding path the action is aimed + * at, the answer `answer_question` will send in the hire's own words, and the description of a + * step `add_path_step` would add. + */ + @SerialName("step_id") val stepId: String? = null, + @SerialName("question_id") val questionId: String? = null, + @SerialName("phase_id") val phaseId: String? = null, + val answer: String? = null, + val description: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt index 7c56e0d4..63f8dfc5 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt @@ -27,4 +27,26 @@ data class BuddyActionRequest( */ val competencyKey: String? = null, val level: String? = null, + /** + * The path node a path action is aimed at: [stepId] for `complete_step`, [questionId] for + * `answer_question`, [phaseId] for `add_path_step`. + * + * Echoed back verbatim like every other payload here, and re-resolved server-side through the + * caller's *own* path — so an id that belongs to somebody else's onboarding is not found rather + * than acted on. + */ + val stepId: UUID? = null, + val questionId: UUID? = null, + val phaseId: UUID? = null, + /** + * The hire's answer to a knowledge question, in their own words, for `answer_question`. + * + * Matched to an option server-side for a multiple-choice question rather than being sent as an + * option id, for the same reason `record_assessment` re-reads the level from its word: what is + * recorded should be derived from what the hire was shown, not from something a client + * substituted afterwards. + */ + val answer: String? = null, + /** What a step added by `add_path_step` is about, one or two sentences. */ + val description: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 086dc722..9daf3852 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -38,7 +38,7 @@ import java.util.UUID * project the buddy did not scope it to, nor act as another hire. */ @Service -@Suppress("TooManyFunctions") // Six wrapped actions, each with a propose + a perform helper. +@Suppress("TooManyFunctions") // Nine wrapped actions, each with a propose + a perform helper. class BuddyActionService( private val taskZeroService: TaskZeroService, private val taskOrientationService: TaskOrientationService, @@ -48,9 +48,16 @@ class BuddyActionService( private val attestationService: AttestationService, private val boardService: BoardService, private val competencyPlacementService: CompetencyPlacementService, + private val buddyPathActions: BuddyPathActions, ) { - /** The action tools the AI reasoner is told it may propose, alongside the read-only tools. */ - fun actionSpecs(): List = + /** + * The action tools the AI reasoner is told it may propose, alongside the read-only tools. + * + * Per hire rather than globally, for the same reason [BuddyToolExecutor.toolSpecs] is: the three + * path actions have a subject that may not exist. A mentor handed `complete_step` for somebody + * with no onboarding path will offer to tick a step off a plan they have not got. + */ + fun actionSpecs(userId: UUID): List = listOf( FLAG_TO_PM_SPEC, CLAIM_TASK_ZERO_SPEC, @@ -59,7 +66,7 @@ class BuddyActionService( REQUEST_ATTESTATION_SPEC, SET_GITHUB_LOGIN_SPEC, RECORD_ASSESSMENT_SPEC, - ) + ) + buddyPathActions.specs(userId) /** Whether [toolName] is an action tool (handled by [propose]) rather than a read-only tool. */ fun isAction(toolName: String): Boolean = BuddyActionType.fromToolName(toolName) != null @@ -91,6 +98,14 @@ class BuddyActionService( return proposeAssessment(call, type) } + // Also before the project gate, and for a reason worth stating: an onboarding path belongs + // to a *person*. It is generated from one project's blueprint, but the path itself is not + // project-scoped, so gating these would refuse a hire onboarding on two projects — and they + // still have exactly one path, sitting on the page they are looking at. + if (buddyPathActions.handles(type)) { + return buddyPathActions.propose(call, type, userId) + } + val project = when (val resolution = resolveProject(userId)) { is ProjectResolution.Resolved -> resolution ProjectResolution.None -> @@ -317,6 +332,17 @@ class BuddyActionService( } } + // And again: a path belongs to a person, not to a project. See the note in `propose`. + if (buddyPathActions.handles(type)) { + return try { + withContext(Dispatchers.IO) { buddyPathActions.perform(type, authId, request) } + } catch (ex: ResponseStatusException) { + // A precondition the underlying route owns (a step that is already finished, a + // question that is not theirs). Relay its sentence rather than failing the confirm. + BuddyActionResponse(ok = false, message = ex.reason ?: "That didn't go through.") + } + } + val context = withContext(Dispatchers.IO) { resolveContext(authId) } val resolved = when (context) { is CallerContext.Resolved -> context @@ -360,6 +386,9 @@ class BuddyActionService( // sits behind. BuddyActionType.SET_GITHUB_LOGIN, BuddyActionType.RECORD_ASSESSMENT, + BuddyActionType.COMPLETE_STEP, + BuddyActionType.ANSWER_QUESTION, + BuddyActionType.ADD_PATH_STEP, -> error("handled above") } } @@ -539,6 +568,10 @@ class BuddyActionService( // Unused for the same reason: not project-scoped, so it never reaches the no-project // reason lines. BuddyActionType.RECORD_ASSESSMENT -> "record where a chat placed you" + // Unused for the same reason again: a path is not project-scoped either. + BuddyActionType.COMPLETE_STEP -> "tick a step off their path" + BuddyActionType.ANSWER_QUESTION -> "send an answer to a question" + BuddyActionType.ADD_PATH_STEP -> "add a step to their path" } /** The result of proposing an action: what to tell the AI, and the proposal to show the hire (if any). */ @@ -562,6 +595,16 @@ class BuddyActionService( /** `record_assessment` confirm payload: which competency, and the level in words. */ val competencyKey: String? = null, val level: String? = null, + /** + * Path-action confirm payloads: the node of the hire's own path the action names, the answer + * `answer_question` would send in the hire's own words, and the description of a step + * `add_path_step` would add. + */ + val stepId: UUID? = null, + val questionId: UUID? = null, + val phaseId: UUID? = null, + val answer: String? = null, + val description: String? = null, ) private sealed interface ProjectResolution { diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt new file mode 100644 index 00000000..0ac04d4d --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -0,0 +1,536 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.BuddyActionType +import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType +import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto +import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.question.SubmitQuestionAttemptRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest +import com.sprintstart.sprintstartbackend.onboarding.model.response.buddy.BuddyActionResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse +import com.sprintstart.sprintstartbackend.user.external.UserApi +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException +import java.util.UUID + +/** + * The three actions that let a conversation move the hire along their onboarding path. + * + * A component of its own rather than three more branches in [BuddyActionService], for the reason + * [BuddyBoardTools] is one: they share a subject that the rest of the catalog does not touch, and + * they come as a set. [BuddyActionService] still owns the propose/confirm contract — it routes to + * this and emits what comes back — so there is exactly one place where an action becomes a button. + * + * ### The line these three do not cross + * + * They write to **the hire's own copy of the path**, never to the blueprint it was copied from. The + * curriculum is the PM's: a mentor that could edit it is a mentor whose team stops trusting it. + * Everything here is undoable on the hire's own onboarding page, which is what makes proposing any + * of it reasonable. + * + * ### Why they sit outside the project gate + * + * An onboarding path belongs to a *person*. It is generated from one project's blueprint, but the + * path itself is not project-scoped — so gating these would refuse a hire onboarding on two + * projects, who still has exactly one path, on the page they are looking at. + */ +@Component +// One propose and one perform per action, plus the argument readers. The count tracks how many +// actions there are. +@Suppress("TooManyFunctions") +class BuddyPathActions( + private val buddyPathTools: BuddyPathTools, + private val onboardingStepService: OnboardingStepService, + private val questionAttemptService: QuestionAttemptService, + private val userApi: UserApi, +) { + /** + * The path actions, or none at all for a hire without a path. + * + * Gated on the path's existence and on nothing else — never on how far along it they are. A + * mentor that may only propose something once some further condition holds is one whose refusals + * the hire has to learn; each proposal checks its own preconditions, where the reason can be a + * sentence. Asked of [BuddyPathTools], so the read tool and these actions can never disagree + * about whether there is a path. + */ + fun specs(userId: UUID): List = + if (!buddyPathTools.hasPath(userId)) { + emptyList() + } else { + listOf(COMPLETE_STEP_SPEC, ANSWER_QUESTION_SPEC, ADD_PATH_STEP_SPEC) + } + + /** Whether [type] is one of this component's actions. */ + fun handles(type: BuddyActionType): Boolean = + type == BuddyActionType.COMPLETE_STEP || + type == BuddyActionType.ANSWER_QUESTION || + type == BuddyActionType.ADD_PATH_STEP + + /** + * Offers one of the three, checked against the hire's own path before the hire sees a button. + * + * Every precondition is resolved here rather than at confirm time, for the reason the assessment + * proposal gives: a step that is already finished, a locked question, an answer that matches no + * option — discovered now, each is a correction the mentor can act on mid-sentence; discovered + * after the click, each is a hire pressing a button and being told no. + * + * The refusals are addressed to the mentor, not to the hire, and each says what to do instead. A + * tool result that only says "no" is one the model relays as a broken feature. + */ + fun propose( + call: BuddyToolCallDto, + type: BuddyActionType, + userId: UUID, + ): BuddyActionService.ProposeOutcome = + when (type) { + BuddyActionType.COMPLETE_STEP -> proposeCompleteStep(call, type, userId) + BuddyActionType.ANSWER_QUESTION -> proposeAnswer(call, type, userId) + else -> proposeAddPathStep(call, type, userId) + } + + /** Runs a confirmed path action. Each underlying `/me/...` operation owns its own rules. */ + fun perform( + type: BuddyActionType, + authId: String, + request: BuddyActionRequest, + ): BuddyActionResponse = + when (type) { + BuddyActionType.COMPLETE_STEP -> completeStep(authId, request.stepId) + BuddyActionType.ANSWER_QUESTION -> answerQuestion(authId, request.questionId, request.answer) + else -> addPathStep(authId, request.phaseId, request.title, request.description) + } + + /** + * Offers to tick a step of the hire's path off. + * + * The button names the step, and the tool result tells the mentor to *ask* rather than to + * announce: the one thing this must never become is a mentor that marks work done because the + * conversation went well. Only the hire knows whether they did it. + */ + private fun proposeCompleteStep( + call: BuddyToolCallDto, + type: BuddyActionType, + userId: UUID, + ): BuddyActionService.ProposeOutcome { + val stepId = call.uuidArg("step_id") + ?: return refused( + "No step_id was provided. Read get_my_onboarding_path and pass the step_id of the " + + "step they mean.", + ) + val step = buddyPathTools.findStep(userId, stepId) + ?: return refused( + "No step of this hire's own path has that id. Read get_my_onboarding_path again — an " + + "id from anywhere else is not theirs to tick off.", + ) + + val refusal = when { + step.status == StepStatus.FINISHED -> + "“${step.title}” is already done, so there is nothing to tick. Tell them it is " + + "already behind them." + step.status == StepStatus.SKIPPED -> + "“${step.title}” was skipped, so it cannot be completed. If that was wrong, their PM " + + "is the one who can undo it." + step.locked -> + "“${step.title}” is locked: something it waits on is not finished. Say what it is " + + "waiting on rather than offering to tick it." + else -> null + } + if (refusal != null) return refused(refusal) + + return BuddyActionService.ProposeOutcome( + toolResult = "Proposed to the hire: mark “${step.title}” as done. They see a confirm " + + "button and nothing changes unless they click it. Ask whether they have actually " + + "done it — never say that it is done.", + proposal = BuddyActionService.BuddyActionProposal( + action = type.toolName, + label = "Mark “${step.title}” as done", + question = null, + stepId = step.id, + ), + ) + } + + /** + * Offers to send the hire's own answer to a knowledge question. + * + * ### Why the answer is text and not an option id + * + * The mentor is not told which option is correct (see [BuddyPathTools]), so what it passes here + * is what the hire said — "the second one", "the retro", the words themselves — and the match to + * an option happens server-side in [matchOption], once here and once at confirm time, from the + * same input. That is deliberate: an option id in the proposal would be the mentor choosing, and + * the point is that the hire chooses. + * + * A match that cannot be made comes back naming the options, so the mentor asks again instead of + * guessing on the hire's behalf. + */ + private fun proposeAnswer( + call: BuddyToolCallDto, + type: BuddyActionType, + userId: UUID, + ): BuddyActionService.ProposeOutcome { + val questionId = call.uuidArg("question_id") + ?: return refused( + "No question_id was provided. Read get_my_onboarding_path and pass the question_id " + + "of the question they are answering.", + ) + val question = buddyPathTools.findQuestion(userId, questionId) + ?: return refused( + "No question of this hire's own path has that id. Read get_my_onboarding_path again.", + ) + val answer = call.stringArg("answer").trim() + + val refusal = when { + answer.isBlank() -> + "No answer was provided. This has to be the hire's own answer in their own words — " + + "ask them what they want to send, and never answer it for them." + question.status == QuestionStatus.PASSED -> + "They have already passed “${question.question}”, so there is nothing to send. Tell " + + "them it is behind them." + question.status == QuestionStatus.LOCKED -> + "“${question.question}” is locked: something it waits on is not finished yet. Say " + + "what it is waiting on rather than offering to answer it." + else -> null + } + if (refusal != null) return refused(refusal) + + val shown = if (question.type == CheckQuestionType.MULTIPLE_CHOICE) { + matchOption(question, answer)?.label + ?: return refused( + "“$answer” does not match exactly one of the options for that question. The " + + "options are: ${question.options.joinToString("; ") { it.label }}. Ask the " + + "hire which of them they mean and pass that back.", + ) + } else { + answer + } + + return BuddyActionService.ProposeOutcome( + toolResult = "Proposed to the hire: send “$shown” as their answer to " + + "“${question.question}”. They see a confirm button showing that answer, and nothing " + + "is recorded unless they click it. An attempt is kept whether it is right or not, so " + + "make sure it is the answer they meant — and do not tell them whether it is correct, " + + "because you have not been told either.", + proposal = BuddyActionService.BuddyActionProposal( + action = type.toolName, + label = "Send this answer: “${shown.take(ANSWER_LABEL_LIMIT)}”", + question = null, + questionId = question.id, + answer = answer, + ), + ) + } + + /** + * Offers to add a step the conversation produced to a phase of the hire's own path. + * + * The one action here that writes something the model wrote, so the line is worth restating: it + * adds to the hire's copy, their PM's blueprint is untouched, and the hire can edit or delete it + * on their own page like any other step. + * + * A description is required rather than optional. A title on its own is a step somebody has to + * guess at, and the phase it lands in is usually one that came back empty — precisely the case + * where the hire has nothing else to go on. + */ + private fun proposeAddPathStep( + call: BuddyToolCallDto, + type: BuddyActionType, + userId: UUID, + ): BuddyActionService.ProposeOutcome { + val phaseId = call.uuidArg("phase_id") + ?: return refused( + "No phase_id was provided. Read get_my_onboarding_path and pass the phase_id of the " + + "phase the step belongs in.", + ) + val phase = buddyPathTools.findPhase(userId, phaseId) + ?: return refused( + "No phase of this hire's own path has that id. Read get_my_onboarding_path again.", + ) + val title = call.stringArg("title").trim() + val description = call.stringArg("description").trim() + + val refusal = when { + title.isBlank() -> "No title was provided. Say what the step is, in a few words." + description.isBlank() -> + "No description was provided. A step with a title and nothing else is one the hire " + + "has to guess at — say what doing it involves, then offer it again." + phase.steps.any { it.title.trim().equals(title, ignoreCase = true) } -> + "“$title” is already a step of “${phase.title}”, so nothing needs adding. Point them " + + "at the one that is there." + else -> null + } + if (refusal != null) return refused(refusal) + + return BuddyActionService.ProposeOutcome( + toolResult = "Proposed to the hire: add the step “$title” to the phase " + + "“${phase.title}” of their own path. They see a confirm button; nothing is added " + + "unless they click it. This changes their copy only — their PM's blueprint is " + + "untouched — and they can edit or remove it afterwards. Say what the step is for " + + "before you offer it.", + proposal = BuddyActionService.BuddyActionProposal( + action = type.toolName, + label = "Add “$title” to your path", + question = null, + phaseId = phase.id, + title = title, + description = description, + ), + ) + } + + private fun completeStep(authId: String, stepId: UUID?): BuddyActionResponse { + if (stepId == null) { + return BuddyActionResponse(ok = false, message = "No step was proposed to complete.") + } + val step = onboardingStepService.completeOnboardingStepForMe(authId, stepId) + return BuddyActionResponse( + ok = true, + message = "Ticked off “${step.title}”. It is done on your path — if that was too early, " + + "you can reopen it there.", + ) + } + + /** + * Records the confirmed answer, and relays what came back. + * + * `ok` is true for a wrong answer too, and that is not an oversight: the action was to *send* + * their answer, and it was sent. Whether it was right is the message's job — a wrong attempt + * shown as a failed action would read as the buddy having broken rather than as the hire having + * missed, on the one surface where missing is supposed to be ordinary. + */ + private fun answerQuestion(authId: String, questionId: UUID?, answer: String?): BuddyActionResponse { + if (questionId == null || answer.isNullOrBlank()) { + return BuddyActionResponse(ok = false, message = "No answer was proposed to send.") + } + val question = buddyPathTools.findQuestion(resolveUserId(authId), questionId) + ?: return BuddyActionResponse(ok = false, message = "That question isn't on your path.") + + val submission = if (question.type == CheckQuestionType.MULTIPLE_CHOICE) { + val option = matchOption(question, answer) + ?: return BuddyActionResponse( + ok = false, + message = "I couldn't tell which option “$answer” meant, so nothing was sent.", + ) + SubmitQuestionAttemptRequest(selectedOptionIds = listOf(option.id)) + } else { + SubmitQuestionAttemptRequest(textAnswer = answer) + } + + val result = questionAttemptService.submitQuestionAttemptForMe(authId, questionId, submission) + // The submit response is the one user-facing place correct answers are revealed, and the + // hire's own page shows them there. Relaying the same thing keeps the two surfaces telling + // one story; withholding it here would make the conversation the worse place to answer. + val learning = listOfNotNull(result.feedback, result.explanation).joinToString(" ") + return BuddyActionResponse( + ok = true, + message = if (result.correct) { + "That was right — the question is passed. $learning".trim() + } else { + "Not quite. It stays open, so you can try again whenever you like. $learning".trim() + }, + ) + } + + private fun addPathStep( + authId: String, + phaseId: UUID?, + title: String?, + description: String?, + ): BuddyActionResponse { + if (phaseId == null || title.isNullOrBlank()) { + return BuddyActionResponse(ok = false, message = "No step was proposed to add.") + } + val phase = buddyPathTools.findPhase(resolveUserId(authId), phaseId) + ?: return BuddyActionResponse(ok = false, message = "That phase isn't on your path.") + + val created = onboardingStepService.createOnboardingStepForMe( + authId, + phaseId, + CreateOnboardingStepRequest( + // At the end of the phase. Where a step the conversation produced belongs in + // somebody else's sequence is not something this can know, and the hire can drag it. + position = phase.steps.size, + title = title, + description = description.orEmpty(), + type = StepType.TASK, + estimatedMinutes = ADDED_STEP_MINUTES, + // Left empty on purpose: an expected outcome is a promise about what doing the step + // will leave somebody able to do, and the mentor is not in a position to make one. + expectedOutcome = "", + ), + ) + return BuddyActionResponse( + ok = true, + message = "Added “${created.title}” to “${phase.title}”. It is on your path now, and it " + + "is yours — edit it, reorder it or delete it there like any other step.", + ) + } + + /** + * Which option the hire meant, or null when that is not clear enough to act on. + * + * Three passes, loosening in a fixed order: the label exactly, then a label containing what they + * said, then what they said containing a label. Each pass only counts when it matches *one* + * option — "the first one" against two options that start alike is ambiguous, and guessing there + * would record an answer the hire did not give. + * + * Called from the proposal and from the confirm with the same input, so the option that ends up + * recorded is the one whose label the hire read on the button. + */ + private fun matchOption( + question: GetOnboardingQuestionForUserResponse, + answer: String, + ): QuestionOptionForUserResponse? { + val needle = answer.trim() + if (needle.isBlank()) return null + + val exact = question.options.filter { it.label.trim().equals(needle, ignoreCase = true) } + if (exact.size == 1) return exact.single() + + val labelContains = question.options.filter { it.label.contains(needle, ignoreCase = true) } + if (labelContains.size == 1) return labelContains.single() + + val answerContains = question.options.filter { needle.contains(it.label.trim(), ignoreCase = true) } + return answerContains.singleOrNull() + } + + /** A reason and no button, for the mentor to act on rather than for the hire to click past. */ + private fun refused(reason: String) = BuddyActionService.ProposeOutcome(reason, null) + + private fun resolveUserId(authId: String): UUID = + userApi + .getUserIdByAuthId(authId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "No user found with authId: $authId") } + + /** Reads a string argument the model passed to a tool, or "" when it is missing/non-text. */ + private fun BuddyToolCallDto.stringArg(name: String): String = + (arguments[name] as? JsonPrimitive)?.contentOrNull.orEmpty() + + /** Reads a UUID argument the model passed to a tool, or null when it is missing/unparseable. */ + private fun BuddyToolCallDto.uuidArg(name: String): UUID? = + runCatching { UUID.fromString(stringArg(name)) }.getOrNull() + + private companion object { + /** How many characters of an answer the confirm button shows before it is cut. */ + const val ANSWER_LABEL_LIMIT = 60 + + /** + * The estimate a step the buddy added carries. + * + * Deliberately a constant the mentor cannot choose. How long a piece of work takes somebody + * else is not something a model is in a position to say, and a confident "45 min" on a step + * it invented is worse than a modest default the hire can correct on their own page. + */ + const val ADDED_STEP_MINUTES = 15 + + val COMPLETE_STEP_SPEC = BuddyToolSpecDto( + name = BuddyActionType.COMPLETE_STEP.toolName, + description = "Offer to mark one step of the hire's onboarding path as done. Read " + + "get_my_onboarding_path first and pass that step's step_id. This does NOT complete " + + "anything by itself: the hire sees a confirm button naming the step, and only they " + + "can click it. Use it when they say they have finished something — never because " + + "the conversation went well, and never to tidy up their path. Only they know " + + "whether the work is done, so ask; do not announce it as done.", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("step_id") { + put("type", "string") + put("description", "The step_id from get_my_onboarding_path.") + } + } + putJsonArray("required") { add("step_id") } + }, + ) + + val ANSWER_QUESTION_SPEC = BuddyToolSpecDto( + name = BuddyActionType.ANSWER_QUESTION.toolName, + description = "Offer to send the hire's answer to one knowledge-check question on their " + + "path. Read get_my_onboarding_path for the question_id and the options they are " + + "looking at. Pass THEIR answer, in their words — for a multiple-choice question, " + + "whichever option they picked; it is matched to an option for you, and an answer " + + "that matches none comes back so you can ask again. This does NOT record anything " + + "by itself; they see a confirm button showing the answer that will be sent. An " + + "attempt is kept whether it is right or wrong. You are a tutor here, not an " + + "examiner and not a shortcut: explain the material the question is about, from the " + + "project's own documents, and let them answer it. You are not told which answer is " + + "correct — so never state one, never hint at one, and never pass an answer they did " + + "not give. If they ask you to just tell them, say plainly that you do not know it " + + "and offer to go through the material instead.", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("question_id") { + put("type", "string") + put("description", "The question_id from get_my_onboarding_path.") + } + putJsonObject("answer") { + put("type", "string") + put( + "description", + "The hire's own answer, in their own words. For multiple choice, the " + + "option they picked — the label, or enough of it to identify it.", + ) + } + } + putJsonArray("required") { + add("question_id") + add("answer") + } + }, + ) + + val ADD_PATH_STEP_SPEC = BuddyToolSpecDto( + name = BuddyActionType.ADD_PATH_STEP.toolName, + description = "Offer to add one step to a phase of the hire's OWN copy of their " + + "onboarding path. Read get_my_onboarding_path for the phase_id. This does NOT add " + + "anything by itself; the hire confirms, and afterwards the step is theirs to edit, " + + "reorder or delete. It never touches their PM's blueprint — the curriculum is the " + + "PM's, and you are not editing it. Use it for two things and little else: a phase " + + "that came back empty, where the two of you have worked out something concrete it " + + "should contain; and something real the hire is stuck on that their path does not " + + "mention, so it stops living in a conversation that is gone tomorrow. Give a title " + + "of a few words and a description saying what doing it involves. Do not offer a " + + "step for something already on their path, do not add several at once, and do not " + + "add one just to have added something.", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("phase_id") { + put("type", "string") + put("description", "The phase_id from get_my_onboarding_path.") + } + putJsonObject("title") { + put("type", "string") + put("description", "What the step is, in a few words.") + } + putJsonObject("description") { + put("type", "string") + put( + "description", + "What doing it involves, in one or two sentences the hire can act on.", + ) + } + } + putJsonArray("required") { + add("phase_id") + add("title") + add("description") + } + }, + ) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt new file mode 100644 index 00000000..149b6957 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -0,0 +1,441 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto +import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * The buddy's path tool: the onboarding curriculum the hire is actually walking. + * + * Until this existed the mentor could not see the plan it was supposed to be mentoring. It knew + * about arrival steps, pull requests, competencies and the board -- everything *around* the + * onboarding path -- and nothing about the phases, steps and knowledge questions the hire's PM + * prescribed. So "what should I do next" was answered out of the work pool while the hire sat on a + * page telling them something else: two plans, and nothing saying which one was the plan. + * + * The division of labour this establishes, and which the tool descriptions restate where the model + * will read them: + * + * - The **blueprint** is the PM's. The buddy never touches it; a mentor that could rewrite the + * curriculum is a mentor whose team stops trusting the curriculum. + * - The **hire's copy** is the hire's, and the buddy may propose changes to it -- see + * [BuddyActionService], where every one of them waits for a button. + * - **Reading really is free of consequence here**, unlike [BuddyBoardTools.execute]'s board read, + * which brings a board's baseline cards up to date by looking. Nothing is created by asking. + * + * ### What this deliberately does not do + * + * It does not read the whole path out. Full detail for the phase the hire is standing in, titles + * only for what is ahead -- a mentor that recites sixteen phases has produced a table of contents, + * and the hire already has one: it is the page they are looking at. That also bounds the prompt, + * which a sixteen-phase path with steps in every phase otherwise would not. + * + * It does not say which answer to a knowledge question is correct, because it is not told: the + * hire-facing response never carries the correct option. The tutoring this enables is explaining the + * material, never handing over the answer, and the surest way to keep it that way is that the + * mentor cannot know it either. + */ +@Component +// One function per section of what a path has to say, plus the two entry points. The count tracks +// the shape of the text, not a class doing unrelated things. +@Suppress("TooManyFunctions") +class BuddyPathTools( + private val onboardingPathService: OnboardingPathService, +) { + /** + * The path tool, mounted only for a hire who has a path. + * + * "Absent, never empty", the same rule the arrival and pull-request tools follow: a tool whose + * only possible answer is "there is no path" is one the mentor will raise the path with anyway. + * Asked as a row count rather than as a read -- see [OnboardingPathService.hasPath]. + */ + fun toolSpecs(userId: UUID): List = + if (onboardingPathService.hasPath(userId)) listOf(READ_MY_PATH_SPEC) else emptyList() + + /** Whether [toolName] is this component's tool. */ + fun handles(toolName: String): Boolean = toolName == READ_MY_PATH + + /** + * Whether this hire has a path at all. + * + * Exposed so that [BuddyActionService] gates its path actions on the same question this gates + * its read tool on, answered by the same call. Two components deciding separately whether a path + * exists is how a mentor ends up holding an action for a plan its read tool says is not there. + */ + fun hasPath(userId: UUID): Boolean = onboardingPathService.hasPath(userId) + + /** + * What the hire's path says right now, as plain text for the model. + * + * Every branch returns a sentence, including the ones where there is nothing to report. A tool + * that answers with silence is a tool whose result the model fills in for itself. + */ + fun execute(userId: UUID): String { + val path = onboardingPathService.findPathForUserId(userId) ?: return NO_PATH + val phases = path.phases.sortedBy { it.position } + if (phases.isEmpty()) return NO_PHASES + + val currentIndex = phases.indexOfFirst { it.isOpen() }.takeIf { it >= 0 } ?: phases.lastIndex + + return buildString { + appendLine(standing(phases, currentIndex)) + appendLine() + appendCurrentPhase(phases[currentIndex], currentIndex, phases.size) + appendNextItem(phases) + appendAhead(phases, currentIndex) + appendEmptyPhases(path) + append(NEWLINE + CLOSING) + } + } + + /** + * The path in one or two sentences, for the opening greeting to ground itself in, or null when + * the hire has no path. + * + * Written for the greeting rather than reusing [execute]'s text, on the same reasoning the + * competency greeting line follows: [execute] is addressed to a reasoner holding tools, and + * telling the opener to "offer add_path_step" would put a tool name in front of the hire. + * Absent entirely when there is no path, so a greeting can never open by discussing one. + */ + fun snapshotFor(userId: UUID): String? { + val path = onboardingPathService.findPathForUserId(userId) ?: return null + val phases = path.phases.sortedBy { it.position } + if (phases.isEmpty()) { + return "Onboarding path:\nTheir path has no phases in it, so there is nothing on it to do yet." + } + + val currentIndex = phases.indexOfFirst { it.isOpen() }.takeIf { it >= 0 } ?: phases.lastIndex + val current = phases[currentIndex] + + return buildString { + appendLine("Onboarding path:") + if (current.isOpen()) { + appendLine("They are in phase ${currentIndex + 1} of ${phases.size}: ${quoted(current.title)}.") + nextItem(phases)?.let { appendLine("The next thing waiting for them is ${it.plain}.") } + } else { + appendLine("They have finished every phase of their path.") + } + val empty = path.generationIssues + if (empty.isNotEmpty()) { + append( + "${empty.size} of their phases came back with nothing in them, so there is " + + "nothing in those to do.", + ) + } + }.trim() + } + + /** + * One phase of the hire's own path by id, or null when there is no such phase of theirs. + * + * Resolved *through the hire's own path*, which is what makes it the authorization check as + * well as the lookup: an id the mentor picked up from anywhere else is simply not found here, so + * a proposal can never be aimed at somebody else's onboarding. [findStep] and [findQuestion] + * are the same idea for the other two kinds of node. + * + * Used by [BuddyActionService] to check a proposal *before* the hire sees a button, and to put + * the real title on it. A button that names the thing it will change is the last chance anybody + * has to notice the mentor meant a different step. + */ + fun findPhase(userId: UUID, phaseId: UUID): GetOnboardingPhaseForUserResponse? = + onboardingPathService.findPathForUserId(userId)?.phases?.firstOrNull { it.id == phaseId } + + /** One step of the hire's own path by id, or null. See [findPhase] for why it resolves this way. */ + fun findStep(userId: UUID, stepId: UUID): GetOnboardingStepsResponse? = + onboardingPathService + .findPathForUserId(userId) + ?.phases + ?.flatMap { it.steps } + ?.firstOrNull { it.id == stepId } + + /** One question of the hire's own path by id, or null. See [findPhase]. */ + fun findQuestion(userId: UUID, questionId: UUID): GetOnboardingQuestionForUserResponse? = + onboardingPathService + .findPathForUserId(userId) + ?.phases + ?.flatMap { it.questions } + ?.firstOrNull { it.id == questionId } + + /** Where they are, and how much of the path is behind them. */ + private fun standing(phases: List, currentIndex: Int): String { + if (!phases[currentIndex].isOpen()) { + return "The hire's onboarding path has ${phases.size} phases and every one of them is " + + "finished. There is nothing left on it." + } + // Phases behind them, never a percentage. A path mixes steps they ticked, questions they + // answered and phases that came back empty; one number over those is a figure the mentor + // would repeat and nobody could act on -- the same rule the arrival tool states at length. + val behind = if (currentIndex == 0) { + "It is their first phase." + } else { + "The $currentIndex before it are behind them." + } + return "The hire's onboarding path has ${phases.size} phases. They are standing in phase " + + "${currentIndex + 1}. $behind" + } + + /** The phase they are in, in full: what it is for, its steps, and its questions. */ + private fun StringBuilder.appendCurrentPhase( + phase: GetOnboardingPhaseForUserResponse, + index: Int, + total: Int, + ) { + appendLine("Phase ${index + 1} of $total: ${quoted(phase.title)} [phase_id: ${phase.id}]") + phase.description.takeIf { it.isNotBlank() }?.let { appendLine("What it is for: $it") } + + val steps = phase.steps.sortedBy { it.position } + val questions = phase.questions.sortedBy { it.position } + + if (steps.isEmpty() && questions.isEmpty()) { + appendLine( + "This phase has nothing in it -- no steps, no questions. Nothing was generated for " + + "it, and waiting will not change that.", + ) + return + } + + if (steps.isNotEmpty()) { + appendLine("Steps, in the order the path puts them:") + steps.take(ITEMS_SHOWN).forEach { appendStep(it) } + if (steps.size > ITEMS_SHOWN) appendLine("- and ${steps.size - ITEMS_SHOWN} more") + } + + if (questions.isNotEmpty()) { + appendLine( + "Knowledge questions. They count like steps, so a phase whose steps are done and " + + "whose questions are unanswered is still the phase they are standing in:", + ) + questions.take(ITEMS_SHOWN).forEach { appendQuestion(it) } + if (questions.size > ITEMS_SHOWN) appendLine("- and ${questions.size - ITEMS_SHOWN} more") + } + } + + /** One step: what it is, where it stands, and the id an action needs to name it by. */ + private fun StringBuilder.appendStep(step: GetOnboardingStepsResponse) { + val state = when { + step.status == StepStatus.FINISHED -> "done" + step.status == StepStatus.SKIPPED -> "skipped" + step.locked -> "locked, waiting on another item" + step.status == StepStatus.IN_PROGRESS -> "started" + else -> "open" + } + appendLine( + "- [$state] ${quoted(step.title)} (${step.estimatedMinutes} min) [step_id: ${step.id}]", + ) + step.description.takeIf { it.isNotBlank() }?.let { appendLine(" · $it") } + step.expectedOutcomes.take(OUTCOMES_SHOWN).forEach { + appendLine(" · should leave them able to: $it") + } + // A skip already asked for is the one thing about a step whose state is nowhere else in this + // text, and a mentor that cannot see it will offer to request a second one. + step.skip?.let { skip -> + val verdict = when (skip.accepted) { + true -> "their PM accepted it" + false -> "their PM declined it" + null -> "nobody has decided yet" + } + appendLine(" · they asked to skip this ($verdict): ${quoted(skip.reason)}") + } + } + + /** + * One question, as the hire sees it -- and no further. + * + * The options are listed because the hire is looking at them, and a mentor that cannot name them + * has to ask the hire to read their own screen out. Which one is right is not here, and the + * absence is the feature: see the class comment. + */ + private fun StringBuilder.appendQuestion(question: GetOnboardingQuestionForUserResponse) { + val state = when (question.status) { + QuestionStatus.PASSED -> "passed" + QuestionStatus.RETRY -> "answered wrong before, still open" + QuestionStatus.LOCKED -> "locked, waiting on another item" + QuestionStatus.OPEN -> "open" + } + appendLine( + "- [$state] ${quoted(question.question)} (${question.type}) [question_id: ${question.id}]", + ) + val options = question.options.sortedBy { it.position } + if (options.isNotEmpty()) { + appendLine(" · the options they see: " + options.joinToString("; ") { it.label }) + } + } + + /** The one thing to talk about next, named here rather than left to the model to pick. */ + private fun StringBuilder.appendNextItem(phases: List) { + append(NEWLINE) + when (val next = nextItem(phases)) { + null -> appendLine("Nothing on their path is open right now.") + else -> appendLine("The next thing waiting for them: ${next.withIds}.") + } + } + + /** What is ahead, by title only. */ + private fun StringBuilder.appendAhead( + phases: List, + currentIndex: Int, + ) { + val ahead = phases.drop(currentIndex + 1) + if (ahead.isEmpty()) return + + append(NEWLINE) + appendLine( + "Still ahead. Titles only, and deliberately so: do not read this list out, because the " + + "page they are on already lists it.", + ) + ahead.take(AHEAD_SHOWN).forEachIndexed { offset, phase -> + val number = currentIndex + 2 + offset + val locked = if (phase.locked) " (locked until its blockers are done)" else "" + appendLine("- $number. ${quoted(phase.title)}$locked") + } + if (ahead.size > AHEAD_SHOWN) appendLine("- and ${ahead.size - AHEAD_SHOWN} more") + } + + /** + * The phases that came back with nothing in them, and what a conversation may do about it. + * + * This is the one part of a path a conversation can genuinely repair. An AI-enhanced phase whose + * project material was too thin is persisted honestly as empty rather than filled with invented + * advice -- which is right, and leaves the hire with a phase that is a warning badge. Its title + * and description still say what it was *meant* to cover, and that is enough to talk about. + */ + private fun StringBuilder.appendEmptyPhases(path: GetOnboardingPathForUserResponse) { + val issues = path.generationIssues + if (issues.isEmpty()) return + + append(NEWLINE) + appendLine( + "These phases came back with nothing in them, because the project's own material did " + + "not support them:", + ) + issues.take(AHEAD_SHOWN).forEach { appendLine("- ${quoted(it.title)} (${it.status})") } + appendLine( + "That is not the hire's fault and not something trying again fixes. Their titles say " + + "what each was meant to cover, so they are subjects you can talk through -- and if " + + "something concrete comes out of that conversation, offer add_path_step so the " + + "phase stops being empty.", + ) + } + + /** Whether a phase still has anything open: an unfinished step, or an unpassed question. */ + private fun GetOnboardingPhaseForUserResponse.isOpen(): Boolean { + val openStep = steps.any { it.status != StepStatus.FINISHED && it.status != StepStatus.SKIPPED } + return openStep || questions.any { it.status != QuestionStatus.PASSED } + } + + /** + * The first open, unlocked item on the path, mixing steps and questions by position. + * + * The same rule the hire's own page uses to pick its "next" button: phases in order, locked + * phases skipped entirely, then position order inside the phase, whichever kind of item comes + * first. Written here against the hire-facing shape rather than reusing + * [OnboardingPositionReader], which predates questions being first-class and still walks steps + * only -- a mentor using that would send a hire past the question their phase is actually + * waiting on. Two answers to one question is a thing to reconcile, and this comment is where the + * next person will find out that it needs reconciling. + */ + private fun nextItem(phases: List): NextItem? { + for (phase in phases.sortedBy { it.position }) { + if (phase.locked || !phase.isOpen()) continue + + val step = phase.steps + .sortedBy { it.position } + .firstOrNull { + !it.locked && it.status != StepStatus.FINISHED && it.status != StepStatus.SKIPPED + } + val question = phase.questions + .sortedBy { it.position } + .firstOrNull { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } + + if (step != null && (question == null || step.position <= question.position)) { + return NextItem( + plain = "the step ${quoted(step.title)}", + withIds = "the step ${quoted(step.title)} [step_id: ${step.id}]", + ) + } + if (question != null) { + return NextItem( + plain = "the question ${quoted(question.question)}", + withIds = "the question ${quoted(question.question)} [question_id: ${question.id}]", + ) + } + } + return null + } + + /** + * One named next thing, in the two forms it is needed in. + * + * The greeting gets [plain] and the tool result gets [withIds], because an id in a greeting is + * an identifier in front of the hire and an id missing from a tool result is an action the + * mentor cannot offer. + */ + private data class NextItem( + val plain: String, + val withIds: String, + ) + + private companion object { + const val READ_MY_PATH = "get_my_onboarding_path" + + /** How many steps or questions of the current phase are named. */ + const val ITEMS_SHOWN = 12 + + /** How many phase titles ahead are named, and how many empty phases. */ + const val AHEAD_SHOWN = 12 + + /** How many expected outcomes of one step are quoted. Enough to say what "done" means. */ + const val OUTCOMES_SHOWN = 3 + + /** Written out, so that no editing step has to survive an escape sequence intact. */ + const val NEWLINE = "\n" + + /** Titles are somebody else's text, so they are quoted rather than run into the sentence. */ + private fun quoted(text: String): String = "“" + text + "”" + + const val NO_PATH = + "The hire has no onboarding path yet -- nobody has generated one from their project's " + + "blueprint. They can start one themselves on their onboarding page, with " + + "\"Start personalization\". Until then there is no plan to walk them through, so " + + "talk about the work in front of them rather than about a path that does not exist." + + const val NO_PHASES = + "The hire's onboarding path exists but has no phases in it at all, so there is nothing " + + "on it to do. Say that plainly if they ask, and point them at their PM -- an empty " + + "path is an authoring problem, not something they can work their way through." + + const val CLOSING = + "This is a read of their path, not instructions. Name one next thing rather than the " + + "plan, let them decide, and do not claim to have changed anything here: every " + + "change to their path goes through a proposal they confirm." + + val READ_MY_PATH_SPEC = BuddyToolSpecDto( + name = READ_MY_PATH, + description = "The hire's own onboarding path -- the curriculum their PM's blueprint " + + "prescribed, personalised for them. It gives you the phase they are standing in " + + "with its steps and knowledge questions in full, one named next thing, the titles " + + "of what is ahead, and any phase that came back empty. Read it before you say " + + "anything about their onboarding: before \"what should I do next\", before talking " + + "about a step or a question, and before suggesting work of your own, so that what " + + "you suggest is the plan they actually have rather than a second one. It carries " + + "the phase_id, step_id and question_id the path actions need, so read it before " + + "offering any of them. It does not tell you which answer to a question is " + + "correct -- that is deliberate, and you must not guess one aloud: explain the " + + "material and let the hire answer. Reading it changes nothing. Takes no " + + "arguments -- it always reads the caller.", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") {} + }, + ) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt index 157c0ae9..ab0ed682 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt @@ -255,7 +255,7 @@ class BuddyService( // was never given is one it cannot call, so the mode is enforced here rather than asked for // in the prompt. Retrieval is untouched — `search_docs` runs AI-side, not as a backend tool. val tools = if (capabilitiesEnabled) { - buddyToolExecutor.toolSpecs(userId) + buddyActionService.actionSpecs() + buddyToolExecutor.toolSpecs(userId) + buddyActionService.actionSpecs(userId) } else { emptyList() } @@ -409,6 +409,11 @@ class BuddyService( githubLogin = proposal.githubLogin, competencyKey = proposal.competencyKey, level = proposal.level, + stepId = proposal.stepId?.toString(), + questionId = proposal.questionId?.toString(), + phaseId = proposal.phaseId?.toString(), + answer = proposal.answer, + description = proposal.description, ), ) } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt index 7e6d9942..09642709 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt @@ -42,6 +42,7 @@ class BuddyToolExecutor( private val projectMembershipApi: ProjectMembershipApi, private val arrivalStepService: ArrivalStepService, private val competencyPlacementService: CompetencyPlacementService, + private val buddyPathTools: BuddyPathTools, ) { /** * The backend tools the AI reasoner is told it may call, for this hire. @@ -61,6 +62,10 @@ class BuddyToolExecutor( if (arrivalStepService.forHire(userId).isNotEmpty()) { add(GET_ARRIVAL_STEPS_SPEC) } + // Second, and ahead of everything about how their work is going: the path is the *plan*, so + // a mentor that has not read it answers "what should I do next" out of the work pool while + // the hire is looking at a page that says something else. Mounted only when a path exists. + addAll(buddyPathTools.toolSpecs(userId)) add(GET_MY_METRICS_SPEC) add(GET_MY_COMPETENCIES_SPEC) // Mounted only while something is still unplaced, on the same "absent, never empty" rule @@ -97,6 +102,10 @@ class BuddyToolExecutor( listOfNotNull( ("Before they can work:\n" + getArrivalSteps(userId)) .takeIf { arrivalStepService.forHire(userId).isNotEmpty() }, + // Before progress, for the same reason the tool is mounted before the metrics one: the + // plan is what a greeting should open on. Absent entirely for a hire with no path, so a + // greeting can never open by discussing one they have not generated. + buddyPathTools.snapshotFor(userId), "Progress:\n" + getMyMetrics(userId), // Omitted for somebody whose work cannot be found at all, on the same rule as the // tool: a greeting handed "Open pull requests: you have not set a GitHub username" @@ -127,6 +136,7 @@ class BuddyToolExecutor( fun execute(call: BuddyToolCallDto, userId: UUID): String = when { buddyBoardTools.handles(call.name) -> buddyBoardTools.execute(call, userId) + buddyPathTools.handles(call.name) -> buddyPathTools.execute(userId) else -> when (call.name) { GET_ARRIVAL_STEPS -> getArrivalSteps(userId) GET_MY_METRICS -> getMyMetrics(userId) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt index f70e5016..e94131f3 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt @@ -83,6 +83,36 @@ class OnboardingPathService( onboardingPathRepository.deleteByUserId(userId) } + /** + * Whether this user has an onboarding path at all. + * + * A row count rather than a read: the buddy asks this once per turn to decide whether to mount + * its path tool, and loading every phase and step to answer "is there one" would put the + * heaviest read in the module behind a question about its own existence. + */ + fun hasPath(userId: UUID): Boolean = onboardingPathRepository.existsByUserId(userId) + + /** + * The hire's own path as they see it, by user id, or `null` when they have none. + * + * The same read as [getOnboardingPathForMe] — question attempts included, so the statuses are + * the ones on their screen — reached by user id and without the 404. Having no path yet is an + * ordinary state for a caller that is deciding what to say about it, not an error to catch: the + * buddy needs "there is nothing here" as an answer, and a thrown 404 would make every reader + * wrap this in a try. + */ + @Transactional(readOnly = true) + @Tracked("Retrieving onboarding path by user id") + fun findPathForUserId(userId: UUID): GetOnboardingPathForUserResponse? = + onboardingPathRepository + .findOnboardingPathByUserId(userId) + .map { path -> + path.toGetForUserResponse( + passedQuestionIds = questionAttemptRepository.findPassedQuestionIdsByUserId(userId).toSet(), + attemptedQuestionIds = questionAttemptRepository.findAttemptedQuestionIdsByUserId(userId).toSet(), + ) + }.orElse(null) + // ========================== Methods for admins ========================== /** diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt index ee9e4f16..9e9924b8 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt @@ -4,6 +4,7 @@ import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardKin import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProficiencyLevel import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.goal.GoalView import com.sprintstart.sprintstartbackend.onboarding.model.response.orientation.MyOrientationResponse @@ -40,6 +41,15 @@ class BuddyActionServiceTest { // are not about -- the case that asserts it says so explicitly. private val boardService: BoardService = mockk(relaxed = true) private val competencyPlacementService: CompetencyPlacementService = mockk() + + // The path half of the buddy, which owns its own three actions. These cases are about the + // project-scoped ones; `BuddyPathActionTest` covers the other half against the real component. + // Answers "not mine" for every action here, which is what these cases are: the path component + // is asked first for every proposal and every confirm, so a mock that could not say no would + // make every case below a stubbing error. + private val buddyPathActions: BuddyPathActions = mockk { + every { handles(any()) } returns false + } private val service = BuddyActionService( taskZeroService, taskOrientationService, @@ -49,6 +59,7 @@ class BuddyActionServiceTest { attestationService, boardService, competencyPlacementService, + buddyPathActions, ) private val userId = UUID.randomUUID() @@ -106,8 +117,10 @@ class BuddyActionServiceTest { // -- specs / dispatch ------------------------------------------------------------------------- @Test - fun `exposes exactly the seven action tools`() { - assertThat(service.actionSpecs().map { it.name }).containsExactlyInAnyOrder( + fun `exposes the project-scoped action tools, and no path action without a path`() { + every { buddyPathActions.specs(userId) } returns emptyList() + + assertThat(service.actionSpecs(userId).map { it.name }).containsExactlyInAnyOrder( "flag_to_pm", "claim_task_zero", "open_orientation", @@ -118,6 +131,15 @@ class BuddyActionServiceTest { ) } + @Test + fun `mounts whatever path actions the path component offers for this hire`() { + every { buddyPathActions.specs(userId) } returns listOf( + BuddyToolSpecDto(name = "complete_step", description = "", parameters = buildJsonObject { }), + ) + + assertThat(service.actionSpecs(userId).map { it.name }).contains("complete_step") + } + @Test fun `recognises action tools and rejects read tools`() { assertThat(service.isAction("claim_task_zero")).isTrue() diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt new file mode 100644 index 00000000..b81f1138 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -0,0 +1,471 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType +import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.question.SubmitQuestionAttemptRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest +import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.SubmitQuestionAttemptResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.UpdateOnboardingStepResponse +import com.sprintstart.sprintstartbackend.user.external.UserApi +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.security.oauth2.jwt.Jwt +import java.time.Instant +import java.util.Optional +import java.util.UUID + +/** + * The three actions that let a conversation move somebody along their onboarding path. + * + * The through-line, and the reason each of these is a *proposal*: **the mentor may say what it + * thinks, and the hire is the one who changes their own onboarding.** Three rules follow, and every + * case here is an instance of one of them: + * + * 1. Nothing is written until a confirm arrives, so proposing must not touch a service that writes. + * 2. Every precondition is checked before the button, so a hire never clicks one and is told no. + * 3. A refusal is addressed to the mentor and says what to do instead -- a tool result that only + * says "no" is one the model relays to the hire as a broken feature. + */ +class BuddyPathActionTest { + private val buddyPathTools: BuddyPathTools = mockk() + private val onboardingStepService: OnboardingStepService = mockk() + private val questionAttemptService: QuestionAttemptService = mockk() + private val userApi: UserApi = mockk() + + // The real path component behind a real action service: these cases are about the three actions + // *and* about BuddyActionService routing them around the project gate, and mocking the component + // would test the routing against nothing. + private val pathActions = BuddyPathActions( + buddyPathTools = buddyPathTools, + onboardingStepService = onboardingStepService, + questionAttemptService = questionAttemptService, + userApi = userApi, + ) + + private val service = BuddyActionService( + taskZeroService = mockk(relaxed = true), + taskOrientationService = mockk(relaxed = true), + knowledgeBaseService = mockk(relaxed = true), + userGoalService = mockk(relaxed = true), + userApi = userApi, + attestationService = mockk(relaxed = true), + boardService = mockk(relaxed = true), + competencyPlacementService = mockk(relaxed = true), + buddyPathActions = pathActions, + ) + + private val userId = UUID.randomUUID() + private val authId = "auth|hire" + private val jwt: Jwt = mockk().also { every { it.subject } returns authId } + + // -- complete_step ---------------------------------------------------------------------------- + + @Test + fun `completing a step without an id sends the mentor to the read tool`() { + val outcome = service.propose(call("complete_step"), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("get_my_onboarding_path") + } + + @Test + fun `a step that is not on the hire's own path cannot be ticked off`() { + val strangerStep = UUID.randomUUID() + every { buddyPathTools.findStep(userId, strangerStep) } returns null + + val outcome = service.propose(call("complete_step", "step_id" to strangerStep.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("not theirs") + } + + @Test + fun `a step that is already done is not offered again`() { + val step = step("Clone the repository", StepStatus.FINISHED) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_step", "step_id" to step.id.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("already done") + } + + @Test + fun `a locked step is explained rather than offered`() { + val step = step("Deploy to staging", StepStatus.WAITING, locked = true) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_step", "step_id" to step.id.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("is locked") + } + + @Test + fun `the button names the step, and proposing completes nothing`() { + val step = step("Clone the repository", StepStatus.IN_PROGRESS) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_step", "step_id" to step.id.toString()), userId) + + assertThat(outcome.proposal?.label).isEqualTo("Mark “Clone the repository” as done") + assertThat(outcome.proposal?.stepId).isEqualTo(step.id) + // "Ask; do not announce" is in the tool result too, because the model reads that and not + // this test -- and because only the hire knows whether the work is actually done. + assertThat(outcome.toolResult).contains("never say that it is done") + verify(exactly = 0) { onboardingStepService.completeOnboardingStepForMe(any(), any()) } + } + + @Test + fun `a confirmed completion goes through the hire's own endpoint`() = runTest { + val stepId = UUID.randomUUID() + every { onboardingStepService.completeOnboardingStepForMe(authId, stepId) } returns + completed("Clone the repository") + + val result = service.perform(BuddyActionRequest(action = "complete_step", stepId = stepId), jwt) + + assertThat(result.ok).isTrue() + assertThat(result.message).contains("Clone the repository") + // A path belongs to a person, so no project is resolved -- a hire onboarding on two + // projects, or on none yet, still has exactly one path. + verify(exactly = 0) { userApi.getUsersByIds(any()) } + } + + // -- answer_question -------------------------------------------------------------------------- + + @Test + fun `an answer the hire did not give is refused before the button`() { + val question = question("Who runs the retro?", QuestionStatus.OPEN) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + + val outcome = service.propose( + call("answer_question", "question_id" to question.id.toString(), "answer" to " "), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("never answer it for them") + } + + @Test + fun `a question already passed is not asked again`() { + val question = question("Who runs the retro?", QuestionStatus.PASSED) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + + val outcome = service.propose( + call("answer_question", "question_id" to question.id.toString(), "answer" to "the SM"), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("already passed") + } + + @Test + fun `an answer matching no option comes back with the options, so the mentor can ask again`() { + val question = question( + "Which meeting sets the sprint scope?", + QuestionStatus.OPEN, + options = listOf("Sprint planning", "Retro"), + ) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + + val outcome = service.propose( + call("answer_question", "question_id" to question.id.toString(), "answer" to "the daily"), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("Sprint planning; Retro") + } + + @Test + fun `the hire's words are matched to an option, and the button shows which`() { + val question = question( + "Which meeting sets the sprint scope?", + QuestionStatus.OPEN, + options = listOf("Sprint planning", "Retro"), + ) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + + val outcome = service.propose( + call("answer_question", "question_id" to question.id.toString(), "answer" to "planning"), + userId, + ) + + assertThat(outcome.proposal?.label).isEqualTo("Send this answer: “Sprint planning”") + // The answer is carried as the hire said it and re-matched at confirm time, so the option + // that is recorded is the one whose label they read on the button. + assertThat(outcome.proposal?.answer).isEqualTo("planning") + assertThat(outcome.toolResult).contains("do not tell them whether it is correct") + verify(exactly = 0) { questionAttemptService.submitQuestionAttemptForMe(any(), any(), any()) } + } + + @Test + fun `a confirmed answer records the option the label named`() = runTest { + val question = question( + "Which meeting sets the sprint scope?", + QuestionStatus.OPEN, + options = listOf("Sprint planning", "Retro"), + ) + val planning = question.options.first { it.label == "Sprint planning" } + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + val submitted = slot() + every { + questionAttemptService.submitQuestionAttemptForMe(authId, question.id, capture(submitted)) + } returns graded(correct = true, explanation = "Scope is agreed in planning.") + + val result = service.perform( + BuddyActionRequest(action = "answer_question", questionId = question.id, answer = "planning"), + jwt, + ) + + assertThat(submitted.captured.selectedOptionIds).containsExactly(planning.id) + assertThat(result.ok).isTrue() + assertThat(result.message).contains("That was right") + // The submit response is where the product reveals the explanation, on the page as well as + // here; withholding it would make the conversation the worse place to answer. + assertThat(result.message).contains("Scope is agreed in planning.") + } + + @Test + fun `a wrong answer is a recorded attempt, not a failed action`() = runTest { + val question = question("Who runs the retro?", QuestionStatus.OPEN, options = listOf("The SM", "The PO")) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + every { questionAttemptService.submitQuestionAttemptForMe(authId, question.id, any()) } returns + graded(correct = false, feedback = "Not the product owner.") + + val result = service.perform( + BuddyActionRequest(action = "answer_question", questionId = question.id, answer = "The PO"), + jwt, + ) + + // ok = true: the action was to send their answer, and it was sent. A missed question shown + // as a failed action reads as the buddy breaking rather than as an ordinary retry. + assertThat(result.ok).isTrue() + assertThat(result.message).contains("Not quite") + assertThat(result.message).contains("try again") + } + + @Test + fun `a short-text answer is sent as the hire wrote it`() = runTest { + val question = question("What is your definition of done?", QuestionStatus.RETRY) + .copy(type = CheckQuestionType.SHORT_TEXT, options = emptyList()) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + val submitted = slot() + every { + questionAttemptService.submitQuestionAttemptForMe(authId, question.id, capture(submitted)) + } returns graded(correct = true) + + service.perform( + BuddyActionRequest( + action = "answer_question", + questionId = question.id, + answer = "Reviewed, merged and deployed.", + ), + jwt, + ) + + assertThat(submitted.captured.textAnswer).isEqualTo("Reviewed, merged and deployed.") + assertThat(submitted.captured.selectedOptionIds).isEmpty() + } + + // -- add_path_step ---------------------------------------------------------------------------- + + @Test + fun `a step with no description is refused, because a title alone is a guess`() { + val phase = phase("Deployment") + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + call("add_path_step", "phase_id" to phase.id.toString(), "title" to "Learn the deploy"), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("has to guess at") + } + + @Test + fun `a step already on the path is not added a second time`() { + val phase = phase("Setup", steps = listOf(step("Clone the repository", StepStatus.WAITING))) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + call( + "add_path_step", + "phase_id" to phase.id.toString(), + "title" to "clone the REPOSITORY", + "description" to "Get the code onto your machine.", + ), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("already a step") + } + + @Test + fun `the proposal carries the phase, the title and the description`() { + val phase = phase("Deployment") + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + call( + "add_path_step", + "phase_id" to phase.id.toString(), + "title" to "Walk through a release", + "description" to "Sit with whoever cuts the next release.", + ), + userId, + ) + + assertThat(outcome.proposal?.label).isEqualTo("Add “Walk through a release” to your path") + assertThat(outcome.proposal?.phaseId).isEqualTo(phase.id) + assertThat(outcome.proposal?.description).isEqualTo("Sit with whoever cuts the next release.") + // The line that makes this safe to offer at all, stated where the model reads it. + assertThat(outcome.toolResult).contains("their PM's blueprint is untouched") + } + + @Test + fun `a confirmed step lands at the end of the phase, as a task`() = runTest { + val phase = phase("Deployment", steps = listOf(step("Read the runbook", StepStatus.FINISHED))) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + val created = slot() + every { + onboardingStepService.createOnboardingStepForMe(authId, phase.id, capture(created)) + } returns createdStep("Walk through a release") + + val result = service.perform( + BuddyActionRequest( + action = "add_path_step", + phaseId = phase.id, + title = "Walk through a release", + description = "Sit with whoever cuts the next release.", + ), + jwt, + ) + + assertThat(result.ok).isTrue() + // At the end: where a step the conversation produced belongs in somebody else's sequence is + // not something this can know, and the hire can drag it. + assertThat(created.captured.position).isEqualTo(1) + assertThat(created.captured.type).isEqualTo(StepType.TASK) + // No expected outcome: that is a promise about what the step leaves somebody able to do, + // and the mentor is not in a position to make one. + assertThat(created.captured.expectedOutcome).isEmpty() + assertThat(result.message).contains("it is yours") + } + + // -- fixtures --------------------------------------------------------------------------------- + + private fun call(name: String, vararg args: Pair) = BuddyToolCallDto( + id = "c0", + name = name, + arguments = buildJsonObject { args.forEach { (k, v) -> put(k, v) } }, + ) + + private fun phase(title: String, steps: List = emptyList()) = + GetOnboardingPhaseForUserResponse( + id = UUID.randomUUID(), + pathId = UUID.randomUUID(), + position = 0, + title = title, + description = "", + locked = false, + steps = steps, + ) + + private fun step(title: String, status: StepStatus, locked: Boolean = false) = + GetOnboardingStepsResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = 0, + title = title, + description = "", + type = StepType.TASK, + estimatedMinutes = 20, + isAiAssisted = false, + status = status, + completedAt = null, + skip = null, + locked = locked, + ) + + private fun question( + text: String, + status: QuestionStatus, + options: List = emptyList(), + ) = GetOnboardingQuestionForUserResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = 0, + type = CheckQuestionType.MULTIPLE_CHOICE, + question = text, + options = options.mapIndexed { index, label -> + QuestionOptionForUserResponse(id = UUID.randomUUID(), position = index, label = label) + }, + status = status, + ) + + private fun completed(title: String) = UpdateOnboardingStepResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = 0, + title = title, + description = "", + estimatedMinutes = 20, + isAiAssisted = false, + expectedOutcome = "", + status = StepStatus.FINISHED, + completedAt = Instant.EPOCH, + skip = null, + ) + + private fun createdStep(title: String) = CreateOnboardingStepResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = 1, + title = title, + description = "", + type = StepType.TASK, + estimatedMinutes = 15, + isAiAssisted = false, + expectedOutcome = "", + status = StepStatus.WAITING, + ) + + private fun graded( + correct: Boolean, + explanation: String? = null, + feedback: String? = null, + ) = SubmitQuestionAttemptResponse( + attemptId = UUID.randomUUID(), + questionId = UUID.randomUUID(), + correct = correct, + createdAt = Instant.EPOCH, + explanation = explanation, + feedback = feedback, + status = if (correct) QuestionStatus.PASSED else QuestionStatus.RETRY, + ) +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt new file mode 100644 index 00000000..04e4facc --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -0,0 +1,318 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType +import com.sprintstart.sprintstartbackend.onboarding.external.enums.GenerationStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType +import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.path.OnboardingGenerationIssueResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.UUID + +/** + * What the mentor is told about the path the hire is walking. + * + * The through-line: **the mentor gets the phase they are standing in, and not the plan.** A path is + * the one thing on this product that is already written down on a page in front of the hire, so a + * tool that hands the model all sixteen phases produces a mentor that reads a table of contents out. + * The other half is what the tool refuses to carry: no correct answers, and no blended progress + * figure. + */ +class BuddyPathToolsTest { + private val onboardingPathService: OnboardingPathService = mockk() + private val tools = BuddyPathTools(onboardingPathService) + + private val userId = UUID.randomUUID() + + // -- absent, never empty ---------------------------------------------------------------------- + + @Test + fun `a hire with no path is offered no path tool at all`() { + every { onboardingPathService.hasPath(userId) } returns false + + assertThat(tools.toolSpecs(userId)).isEmpty() + assertThat(tools.hasPath(userId)).isFalse() + } + + @Test + fun `a hire with a path is offered exactly the read tool`() { + every { onboardingPathService.hasPath(userId) } returns true + + assertThat(tools.toolSpecs(userId).map { it.name }).containsExactly("get_my_onboarding_path") + } + + @Test + fun `the greeting says nothing at all about a path that does not exist`() { + every { onboardingPathService.findPathForUserId(userId) } returns null + + assertThat(tools.snapshotFor(userId)).isNull() + } + + @Test + fun `called without a path, the tool still answers in a sentence`() { + every { onboardingPathService.findPathForUserId(userId) } returns null + + // Never silence: a tool that answers with nothing is one the model fills in for itself. + assertThat(tools.execute(userId)).contains("no onboarding path yet") + } + + // -- the phase they are standing in ----------------------------------------------------------- + + @Test + fun `the current phase is the first with anything open, questions included`() { + // Every step of phase two is done, so a steps-only rule would put the hire in phase three. + // Its question has not been passed, which is what makes it still their phase. + val path = path( + phase(0, "Overview", steps = listOf(step("Read the wiki", StepStatus.FINISHED))), + phase( + 1, + "Meetings", + steps = listOf(step("Sit in on a standup", StepStatus.FINISHED)), + questions = listOf(question("Who runs the retro?", QuestionStatus.OPEN)), + ), + phase(2, "Deployment", steps = listOf(step("Ship something", StepStatus.WAITING))), + ) + every { onboardingPathService.findPathForUserId(userId) } returns path + + val text = tools.execute(userId) + + assertThat(text).contains("standing in phase 2") + assertThat(text).contains("Who runs the retro?") + // The phase ahead is named, but only by title -- its step is not in the prompt. + assertThat(text).contains("Deployment") + assertThat(text).doesNotContain("Ship something") + } + + @Test + fun `the ids every path action needs are carried, for the current phase`() { + val step = step("Set up the repo", StepStatus.WAITING) + val question = question("What is a definition of done?", QuestionStatus.RETRY) + val phase = phase(0, "Setup", steps = listOf(step), questions = listOf(question)) + every { onboardingPathService.findPathForUserId(userId) } returns path(phase) + + val text = tools.execute(userId) + + assertThat(text).contains("phase_id: ${phase.id}") + assertThat(text).contains("step_id: ${step.id}") + assertThat(text).contains("question_id: ${question.id}") + } + + @Test + fun `a question carries the options the hire sees and no verdict on them`() { + val question = question( + "Which meeting sets the sprint scope?", + QuestionStatus.OPEN, + options = listOf("Planning", "Retro"), + ) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", questions = listOf(question))) + + val text = tools.execute(userId) + + assertThat(text).contains("Planning; Retro") + // The hire-facing shape carries no correct flag, and nothing here invents one. The mentor + // cannot leak an answer it was never given, which is the point of reading this shape. + assertThat(text.lowercase()).doesNotContain("correct") + } + + @Test + fun `progress is phases behind them, never a blended figure`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "One", steps = listOf(step("a", StepStatus.FINISHED))), + phase(1, "Two", steps = listOf(step("b", StepStatus.WAITING))), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("behind them") + // What the system observed and what the hire ticked are different facts; a percentage over + // the two is a number the mentor would repeat and nobody could act on. + assertThat(text).doesNotContain("%") + } + + @Test + fun `one next thing is named, and it is the first unlocked open item`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase( + 0, + "Setup", + steps = listOf( + step("Install the toolchain", StepStatus.FINISHED), + step("Clone the repository", StepStatus.WAITING), + step("Run the tests", StepStatus.WAITING), + ), + ), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("The next thing waiting for them: the step “Clone the repository”") + assertThat(text.substringAfter("next thing waiting")).doesNotContain("Run the tests") + } + + @Test + fun `a locked phase is never where the next thing comes from`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "Gated", locked = true, steps = listOf(step("Deploy to prod", StepStatus.WAITING))), + phase(1, "Open", steps = listOf(step("Read the runbook", StepStatus.WAITING))), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("next thing waiting for them: the step “Read the runbook”") + } + + // -- the empty-phase repair ------------------------------------------------------------------- + + @Test + fun `a phase that generated nothing is named, with what to do about it`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "Setup", steps = listOf(step("Clone the repository", StepStatus.WAITING))), + issues = listOf( + OnboardingGenerationIssueResponse(UUID.randomUUID(), "Deployment", GenerationStatus.SKIPPED), + ), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("Deployment") + assertThat(text).contains("SKIPPED") + // Not the hire's fault, and the one part of a path a conversation can genuinely repair. + assertThat(text).contains("not the hire's fault") + assertThat(text).contains("add_path_step") + } + + @Test + fun `an empty current phase says so rather than listing nothing`() { + every { onboardingPathService.findPathForUserId(userId) } returns path(phase(0, "Deployment")) + + val text = tools.execute(userId) + + assertThat(text).contains("nothing in it") + } + + // -- the greeting ----------------------------------------------------------------------------- + + @Test + fun `the greeting names the phase and the next thing, and no tool`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "Setup", steps = listOf(step("Clone the repository", StepStatus.WAITING))), + ) + + val snapshot = tools.snapshotFor(userId) + + assertThat(snapshot).contains("phase 1 of 1") + assertThat(snapshot).contains("Clone the repository") + // A tool name in the greeting is a tool name in front of the hire; the opener holds none. + assertThat(snapshot).doesNotContain("step_id") + assertThat(snapshot).doesNotContain("get_my_onboarding_path") + } + + @Test + fun `a finished path is reported as finished rather than as its last phase`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "Setup", steps = listOf(step("Clone the repository", StepStatus.FINISHED))), + ) + + assertThat(tools.snapshotFor(userId)).contains("finished every phase") + assertThat(tools.execute(userId)).contains("every one of them is finished") + } + + // -- lookups, which are also the authorization ------------------------------------------------ + + @Test + fun `a node that is not on the hire's own path is simply not found`() { + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(step("Clone the repository", StepStatus.WAITING)))) + + // Resolving through the hire's own path is what makes an id from anywhere else unusable. + assertThat(tools.findStep(userId, UUID.randomUUID())).isNull() + assertThat(tools.findQuestion(userId, UUID.randomUUID())).isNull() + assertThat(tools.findPhase(userId, UUID.randomUUID())).isNull() + } + + @Test + fun `a node on the hire's own path is found with its own title`() { + val step = step("Clone the repository", StepStatus.WAITING) + val phase = phase(0, "Setup", steps = listOf(step)) + every { onboardingPathService.findPathForUserId(userId) } returns path(phase) + + assertThat(tools.findStep(userId, step.id)?.title).isEqualTo("Clone the repository") + assertThat(tools.findPhase(userId, phase.id)?.title).isEqualTo("Setup") + } + + // -- fixtures --------------------------------------------------------------------------------- + + private fun path( + vararg phases: GetOnboardingPhaseForUserResponse, + issues: List = emptyList(), + ) = GetOnboardingPathForUserResponse( + id = UUID.randomUUID(), + userId = userId, + createdAt = Instant.EPOCH, + phases = phases.toList(), + generationIssues = issues, + ) + + private fun phase( + position: Int, + title: String, + locked: Boolean = false, + steps: List = emptyList(), + questions: List = emptyList(), + ) = GetOnboardingPhaseForUserResponse( + id = UUID.randomUUID(), + pathId = UUID.randomUUID(), + position = position, + title = title, + description = "", + locked = locked, + steps = steps, + questions = questions, + ) + + private var stepPosition = 0 + + private fun step(title: String, status: StepStatus, locked: Boolean = false) = + GetOnboardingStepsResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = stepPosition++, + title = title, + description = "", + type = StepType.TASK, + estimatedMinutes = 20, + isAiAssisted = false, + status = status, + completedAt = null, + skip = null, + locked = locked, + ) + + private var questionPosition = 100 + + private fun question( + text: String, + status: QuestionStatus, + options: List = emptyList(), + ) = GetOnboardingQuestionForUserResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = questionPosition++, + type = CheckQuestionType.MULTIPLE_CHOICE, + question = text, + options = options.mapIndexed { index, label -> + QuestionOptionForUserResponse(id = UUID.randomUUID(), position = index, label = label) + }, + status = status, + ) +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt index af4340ab..c5232139 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt @@ -72,7 +72,7 @@ class BuddyServiceTest { fun stubActionDefaults() { // Default: no action tools, and every tool the AI calls is a read-only one. Tests that // exercise an action override these. - every { buddyActionService.actionSpecs() } returns emptyList() + every { buddyActionService.actionSpecs(any()) } returns emptyList() every { buddyActionService.isAction(any()) } returns false // Retrieval is scoped to the hire's projects, so every turn resolves them. Default: none, // which means the AI narrows nothing -- the behaviour before scoping existed. @@ -494,7 +494,7 @@ class BuddyServiceTest { every { buddyToolExecutor.toolSpecs(any()) } returns listOf( BuddyToolSpecDto(name = "get_arrival_steps", description = "", parameters = JsonObject(emptyMap())), ) - every { buddyActionService.actionSpecs() } returns listOf( + every { buddyActionService.actionSpecs(any()) } returns listOf( BuddyToolSpecDto(name = "escalate", description = "", parameters = JsonObject(emptyMap())), ) val requests = mutableListOf() @@ -512,7 +512,7 @@ class BuddyServiceTest { every { buddyToolExecutor.toolSpecs(any()) } returns listOf( BuddyToolSpecDto(name = "get_arrival_steps", description = "", parameters = JsonObject(emptyMap())), ) - every { buddyActionService.actionSpecs() } returns listOf( + every { buddyActionService.actionSpecs(any()) } returns listOf( BuddyToolSpecDto(name = "escalate", description = "", parameters = JsonObject(emptyMap())), ) val requests = mutableListOf() diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt index 2c887676..4b900e20 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt @@ -54,6 +54,14 @@ class BuddyToolExecutorTest { every { topicsFor(any()) } returns emptyList() } + // Pathless, and explicitly rather than relaxed: a relaxed mock would put an empty greeting + // section into the snapshot these cases assert on. + private val buddyPathTools: BuddyPathTools = mockk { + every { toolSpecs(any()) } returns emptyList() + every { snapshotFor(any()) } returns null + every { handles(any()) } returns false + } + private val executor = BuddyToolExecutor( onboardingMetricsService, myCompetencyService, @@ -67,6 +75,9 @@ class BuddyToolExecutorTest { projectMembershipApi, arrivalStepService, competencyPlacementService, + // Pathless by default: every case here is about a tool that reads something other than the + // onboarding path, and "no path" is what keeps the path tool out of their expectations. + buddyPathTools, ) private val userId = UUID.randomUUID() From a98aa456228691ab85dee07dad5841030c3616bc Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sun, 13 Sep 2026 16:51:01 +0200 Subject: [PATCH 03/22] Give the hire a doorway into their path A chip beside the composer, derived from the path tool like every other chip is derived from its own: mounted only for a hire who has a path, so nobody is offered a way into a plan they have not got. It asks where they are rather than for the plan itself -- the plan is a page they already have, and what a hire looking at an empty composer wants is the phase they are standing in and one next thing. Co-Authored-By: Claude Opus 5 --- .../onboarding/service/BuddyPathTools.kt | 7 ++++++- .../onboarding/service/BuddySuggestionService.kt | 8 ++++++++ .../onboarding/service/BuddySuggestionServiceTest.kt | 11 +++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index 149b6957..564ec1d1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -384,7 +384,12 @@ class BuddyPathTools( val withIds: String, ) - private companion object { + /** + * Not private, for the same reason [BuddyToolExecutor]'s tool names are not: the chip catalog in + * [BuddySuggestionService] binds to this constant, so renaming the tool stops that catalog + * compiling rather than quietly offering the hire a doorway the mentor cannot walk through. + */ + companion object { const val READ_MY_PATH = "get_my_onboarding_path" /** How many steps or questions of the current phase are named. */ diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt index ea7d0333..f5563890 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt @@ -66,6 +66,14 @@ class BuddySuggestionService( label = "What do I still need?", question = "What do I still need to get set up?", ), + // Mounted only for a hire who has an onboarding path, so the chip can talk about one + // without checking. Asks where they are rather than for the plan: the mentor answers + // with the phase they are standing in and one next thing, which is what a hire looking + // at an empty composer actually wants -- the plan itself is a page they already have. + BuddyPathTools.READ_MY_PATH to BuddySuggestionResponse( + label = "Where am I on my path?", + question = "Where am I in my onboarding path, and what should I do next?", + ), BuddyToolExecutor.GET_SUGGESTED_TASKS to BuddySuggestionResponse( label = "What should I work on?", question = "What should I work on next?", diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt index 648dbe8f..2fd998e3 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt @@ -36,6 +36,17 @@ class BuddySuggestionServiceTest { .containsExactly("How am I doing?", "What should I work on?") } + @Test + fun `offers the path chip only for a hire who has an onboarding path`() { + mounted(BuddyToolExecutor.GET_MY_METRICS) + assertThat(service.forHire(userId).map { it.label }).doesNotContain("Where am I on my path?") + + // The read tool is mounted only for a hire who has a path, so the chip inherits that gate -- + // nobody is offered a doorway into a plan they have not got. + mounted(BuddyPathTools.READ_MY_PATH, BuddyToolExecutor.GET_MY_METRICS) + assertThat(service.forHire(userId).map { it.label }).contains("Where am I on my path?") + } + /** * The whole point of deriving rather than listing: a chip appears exactly when its tool does. * Unlike the tool, a chip is something the hire *sees*, so getting this wrong is louder. From 631fe3004f2d929280fe7aefd7c5d19a87784ee0 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sun, 13 Sep 2026 18:30:34 +0200 Subject: [PATCH 04/22] Make the path something a hire and a mentor can point at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things testing turned up, all of them about the same gap: the tool said enough for the mentor to describe the path and not enough for the two of them to act on it together. - **Numbers.** Each item carries the number its own page prints — steps in position order, then questions, which is the order the page lists them. "Let's do 3" now means one thing on both sides. - **Links.** Each step, question and phase carries the path that opens it, so "want to take the check?" can arrive as something clickable. They are a contract with the router, so the constants are named and asserted rather than spelled inline. - **Locked, with a reason.** A locked item now says what it waits on, by number and title, and says not to offer it. Told only "locked", the mentor agreed a hire could go ahead with a step their own page refuses to open. - **The checklist of the step they are on**, with its lines and ids — the level a conversation actually happens at ("I've done the first two"). Only that one step, or the prompt would bury the path it describes. - **`complete_task`**, so part of a step can be ticked off without claiming the step. A step may be finished with lines still open, and the tool text says so rather than letting the mentor invent a rule the product does not have. And every path-action refusal now starts with NOT PROPOSED: a refusal written as advice reads, from inside the model, like the offer having been made, and hires were being told to click buttons that were never rendered. Co-Authored-By: Claude Opus 5 --- .../external/enums/BuddyActionType.kt | 10 + .../external/model/BuddyStreamEvent.kt | 1 + .../model/request/buddy/BuddyActionRequest.kt | 8 + .../onboarding/service/BuddyActionService.kt | 4 + .../onboarding/service/BuddyPathActions.kt | 141 ++++++++++- .../onboarding/service/BuddyPathTools.kt | 220 +++++++++++++++--- .../onboarding/service/BuddyService.kt | 1 + .../onboarding/service/BuddyPathActionTest.kt | 103 ++++++++ .../onboarding/service/BuddyPathToolsTest.kt | 161 +++++++++++-- 9 files changed, 602 insertions(+), 47 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt index 703e9a3f..a16b8e23 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt @@ -68,6 +68,16 @@ enum class BuddyActionType( /** Adds a step the conversation produced to a phase of the hire's own path. */ ADD_PATH_STEP("add_path_step", "Add this step to your path"), + + /** + * Ticks one line off the checklist of the step the hire is on. + * + * Separate from [COMPLETE_STEP] because they are different claims, and the product treats them as + * such: a step may be finished with lines still open, and unticking a line reopens a finished + * step. A mentor that could only make the coarse claim would either tick a whole step off for one + * line of progress or do nothing at all. + */ + COMPLETE_TASK("complete_task", "Tick this off"), ; companion object { diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt index 69f0d922..35994805 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt @@ -50,6 +50,7 @@ data class BuddyStreamEvent( @SerialName("step_id") val stepId: String? = null, @SerialName("question_id") val questionId: String? = null, @SerialName("phase_id") val phaseId: String? = null, + @SerialName("onboarding_task_id") val onboardingTaskId: String? = null, val answer: String? = null, val description: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt index 63f8dfc5..35652231 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt @@ -38,6 +38,14 @@ data class BuddyActionRequest( val stepId: UUID? = null, val questionId: UUID? = null, val phaseId: UUID? = null, + /** + * The checklist line `complete_task` would tick off. + * + * Its own field rather than [taskId], which already means a *starter-work* task for `claim_goal`. + * Two different things called a task is confusing enough in the product without one wire field + * standing for both. + */ + val onboardingTaskId: UUID? = null, /** * The hire's answer to a knowledge question, in their own words, for `answer_question`. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 9daf3852..41021610 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -387,6 +387,7 @@ class BuddyActionService( BuddyActionType.SET_GITHUB_LOGIN, BuddyActionType.RECORD_ASSESSMENT, BuddyActionType.COMPLETE_STEP, + BuddyActionType.COMPLETE_TASK, BuddyActionType.ANSWER_QUESTION, BuddyActionType.ADD_PATH_STEP, -> error("handled above") @@ -570,6 +571,7 @@ class BuddyActionService( BuddyActionType.RECORD_ASSESSMENT -> "record where a chat placed you" // Unused for the same reason again: a path is not project-scoped either. BuddyActionType.COMPLETE_STEP -> "tick a step off their path" + BuddyActionType.COMPLETE_TASK -> "tick a line off their checklist" BuddyActionType.ANSWER_QUESTION -> "send an answer to a question" BuddyActionType.ADD_PATH_STEP -> "add a step to their path" } @@ -603,6 +605,8 @@ class BuddyActionService( val stepId: UUID? = null, val questionId: UUID? = null, val phaseId: UUID? = null, + /** The checklist line `complete_task` would tick off. See the request DTO for why not [taskId]. */ + val onboardingTaskId: UUID? = null, val answer: String? = null, val description: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index 0ac04d4d..7c142445 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -10,9 +10,11 @@ import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpe import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.question.SubmitQuestionAttemptRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.task.UpdateOnboardingTaskRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.buddy.BuddyActionResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse import com.sprintstart.sprintstartbackend.user.external.UserApi import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add @@ -27,9 +29,9 @@ import org.springframework.web.server.ResponseStatusException import java.util.UUID /** - * The three actions that let a conversation move the hire along their onboarding path. + * The actions that let a conversation move the hire along their onboarding path. * - * A component of its own rather than three more branches in [BuddyActionService], for the reason + * A component of its own rather than four more branches in [BuddyActionService], for the reason * [BuddyBoardTools] is one: they share a subject that the rest of the catalog does not touch, and * they come as a set. [BuddyActionService] still owns the propose/confirm contract — it routes to * this and emits what comes back — so there is exactly one place where an action becomes a button. @@ -54,6 +56,7 @@ import java.util.UUID class BuddyPathActions( private val buddyPathTools: BuddyPathTools, private val onboardingStepService: OnboardingStepService, + private val onboardingTaskService: OnboardingTaskService, private val questionAttemptService: QuestionAttemptService, private val userApi: UserApi, ) { @@ -70,12 +73,13 @@ class BuddyPathActions( if (!buddyPathTools.hasPath(userId)) { emptyList() } else { - listOf(COMPLETE_STEP_SPEC, ANSWER_QUESTION_SPEC, ADD_PATH_STEP_SPEC) + listOf(COMPLETE_STEP_SPEC, COMPLETE_TASK_SPEC, ANSWER_QUESTION_SPEC, ADD_PATH_STEP_SPEC) } /** Whether [type] is one of this component's actions. */ fun handles(type: BuddyActionType): Boolean = type == BuddyActionType.COMPLETE_STEP || + type == BuddyActionType.COMPLETE_TASK || type == BuddyActionType.ANSWER_QUESTION || type == BuddyActionType.ADD_PATH_STEP @@ -97,6 +101,7 @@ class BuddyPathActions( ): BuddyActionService.ProposeOutcome = when (type) { BuddyActionType.COMPLETE_STEP -> proposeCompleteStep(call, type, userId) + BuddyActionType.COMPLETE_TASK -> proposeCompleteTask(call, type, userId) BuddyActionType.ANSWER_QUESTION -> proposeAnswer(call, type, userId) else -> proposeAddPathStep(call, type, userId) } @@ -109,6 +114,7 @@ class BuddyPathActions( ): BuddyActionResponse = when (type) { BuddyActionType.COMPLETE_STEP -> completeStep(authId, request.stepId) + BuddyActionType.COMPLETE_TASK -> completeTask(authId, request.onboardingTaskId) BuddyActionType.ANSWER_QUESTION -> answerQuestion(authId, request.questionId, request.answer) else -> addPathStep(authId, request.phaseId, request.title, request.description) } @@ -291,6 +297,92 @@ class BuddyPathActions( ) } + /** + * Offers to tick one line off the checklist of a step. + * + * The finer of the two claims, and the reason both exist. A hire who says "I have done the first + * two" has not finished the step, and a mentor holding only `complete_step` would either overstate + * that or drop it. The read tool carries the checklist of the step they are on, which is where the + * task_id comes from. + */ + private fun proposeCompleteTask( + call: BuddyToolCallDto, + type: BuddyActionType, + userId: UUID, + ): BuddyActionService.ProposeOutcome { + val taskId = call.uuidArg("task_id") + ?: return refused( + "No task_id was provided. Read get_my_onboarding_path for the checklist of the step " + + "they are on and pass the task_id of the line they mean.", + ) + val task = findTask(userId, taskId) + ?: return refused( + "No checklist line of this hire's own path has that id. Read get_my_onboarding_path " + + "again — it only carries the checklist of the step they are on, and a line of some " + + "other step is not in front of you.", + ) + if (task.finished) { + return refused( + "“${task.title}” is already ticked off. Tell them it is already done rather than " + + "offering it again.", + ) + } + + return BuddyActionService.ProposeOutcome( + toolResult = "Proposed to the hire: tick “${task.title}” off their checklist. They see a " + + "confirm button and nothing changes unless they click it. Ask whether they have done " + + "it; ticking a line because the conversation covered it is not the same thing.", + proposal = BuddyActionService.BuddyActionProposal( + action = type.toolName, + label = "Tick off “${task.title}”", + question = null, + onboardingTaskId = task.id, + ), + ) + } + + /** + * One checklist line of the hire's own path, or null. + * + * Ownership is the *step's*: a line belongs to this hire exactly when the step it hangs on is on + * their path, which [BuddyPathTools.findStep] answers. That is why the unscoped read by id is safe + * here and would not be on its own. + */ + private fun findTask(userId: UUID, taskId: UUID): GetOnboardingTaskResponse? { + val task = runCatching { onboardingTaskService.getOnboardingTaskById(taskId) }.getOrNull() + ?: return null + return task.takeIf { buddyPathTools.findStep(userId, it.stepId) != null } + } + + /** + * Ticks the confirmed line off. + * + * Read first and written back whole, because the endpoint that owns this takes the task as it + * should now be rather than a patch. Everything else on it is echoed back unchanged: the mentor is + * changing one tick box, not editing the hire's checklist. + */ + private fun completeTask(authId: String, taskId: UUID?): BuddyActionResponse { + if (taskId == null) { + return BuddyActionResponse(ok = false, message = "No checklist line was proposed to tick off.") + } + val task = onboardingTaskService.getOnboardingTaskForMe(authId, taskId) + onboardingTaskService.updateOnboardingTaskForMe( + authId, + taskId, + UpdateOnboardingTaskRequest( + position = task.position, + title = task.title, + description = task.description, + finished = true, + ), + ) + return BuddyActionResponse( + ok = true, + message = "Ticked “${task.title}” off. The step itself is still yours to finish when you " + + "are ready — a checklist does not close it.", + ) + } + private fun completeStep(authId: String, stepId: UUID?): BuddyActionResponse { if (stepId == null) { return BuddyActionResponse(ok = false, message = "No step was proposed to complete.") @@ -407,8 +499,16 @@ class BuddyPathActions( return answerContains.singleOrNull() } - /** A reason and no button, for the mentor to act on rather than for the hire to click past. */ - private fun refused(reason: String) = BuddyActionService.ProposeOutcome(reason, null) + /** + * A reason and no button. + * + * Prefixed, and that prefix is load-bearing. A refusal phrased as ordinary guidance came back + * from testing as the mentor telling the hire to click a button that was never rendered: from the + * model's side a tool result is a tool result, and "say what doing it involves and offer it again" + * reads a lot like "offered". The prefix says the one thing it has to know -- that nothing is on + * screen -- before the advice it should act on. + */ + private fun refused(reason: String) = BuddyActionService.ProposeOutcome(NOT_PROPOSED + reason, null) private fun resolveUserId(authId: String): UUID = userApi @@ -456,6 +556,37 @@ class BuddyPathActions( }, ) + /** + * The prefix every refusal here carries. + * + * Testing found the mentor telling a hire to click a button that was never rendered: a + * refusal written as advice ("say what doing it involves and offer it again") reads, from + * inside the model, a lot like the offer having been made. This says the fact first. + */ + const val NOT_PROPOSED = "NOT PROPOSED — no button was shown to the hire, so do not tell " + + "them to confirm anything. " + + val COMPLETE_TASK_SPEC = BuddyToolSpecDto( + name = BuddyActionType.COMPLETE_TASK.toolName, + description = "Offer to tick one line off the checklist of the step the hire is on. Read " + + "get_my_onboarding_path for the checklist and pass that line's task_id. This does " + + "NOT tick anything by itself; the hire sees a confirm button naming the line. Use it " + + "when they say they have done part of a step — that is what this is for, and it is " + + "why it is separate from complete_step: a step can be finished with lines still open, " + + "and finishing a step is a bigger claim than ticking a line. Ask; do not tick a line " + + "off because the conversation covered it.", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("task_id") { + put("type", "string") + put("description", "The task_id from the checklist in get_my_onboarding_path.") + } + } + putJsonArray("required") { add("task_id") } + }, + ) + val ANSWER_QUESTION_SPEC = BuddyToolSpecDto( name = BuddyActionType.ANSWER_QUESTION.toolName, description = "Offer to send the hire's answer to one knowledge-check question on their " + diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index 564ec1d1..086dcd48 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -50,6 +50,7 @@ import java.util.UUID @Suppress("TooManyFunctions") class BuddyPathTools( private val onboardingPathService: OnboardingPathService, + private val onboardingTaskService: OnboardingTaskService, ) { /** * The path tool, mounted only for a hire who has a path. @@ -89,7 +90,8 @@ class BuddyPathTools( return buildString { appendLine(standing(phases, currentIndex)) appendLine() - appendCurrentPhase(phases[currentIndex], currentIndex, phases.size) + appendCurrentPhase(phases[currentIndex], currentIndex, phases) + appendCurrentTasks(stepTheyAreOn(phases[currentIndex])) appendNextItem(phases) appendAhead(phases, currentIndex) appendEmptyPhases(path) @@ -97,6 +99,20 @@ class BuddyPathTools( } } + /** + * The step whose checklist is worth putting in front of the mentor: the one they have started, or + * else the first one they could start. + * + * Started wins over next, because a hire with something open is talking about that and not about + * what comes after it. Null when the phase has neither, which is when a checklist would be a + * heading over nothing. + */ + private fun stepTheyAreOn(phase: GetOnboardingPhaseForUserResponse): GetOnboardingStepsResponse? { + val ordered = phase.steps.sortedBy { it.position }.filterNot { it.locked } + return ordered.firstOrNull { it.status == StepStatus.IN_PROGRESS } + ?: ordered.firstOrNull { it.status == StepStatus.WAITING } + } + /** * The path in one or two sentences, for the opening greeting to ground itself in, or null when * the hire has no path. @@ -183,14 +199,37 @@ class BuddyPathTools( "${currentIndex + 1}. $behind" } - /** The phase they are in, in full: what it is for, its steps, and its questions. */ + /** + * The phase they are in, in full: what it is for, its steps, and its questions. + * + * Every item carries three things beyond its own text, each for a reason a testing session made + * obvious: + * + * - **A number**, the same number the hire's page prints on the card. It is what lets them say + * "let's do 3" instead of retyping a title, and it only works because both sides derive it the + * same way: steps in position order, then questions in position order (see [numbering]). + * - **A link**, so "want to take the check?" can arrive as something clickable rather than as an + * instruction to go and find it. + * - **What a locked item is waiting on, by name.** "Locked" on its own left the mentor telling a + * hire they could go ahead and do a step the page would not let them open. + */ private fun StringBuilder.appendCurrentPhase( phase: GetOnboardingPhaseForUserResponse, index: Int, - total: Int, + phases: List, ) { - appendLine("Phase ${index + 1} of $total: ${quoted(phase.title)} [phase_id: ${phase.id}]") + appendLine( + "Phase ${index + 1} of ${phases.size}: ${quoted(phase.title)} " + + "[phase_id: ${phase.id}] [link: $PHASE_LINK${phase.id}]", + ) phase.description.takeIf { it.isNotBlank() }?.let { appendLine("What it is for: $it") } + if (phase.locked) { + val waiting = phase.blockerIds.mapNotNull { id -> phases.firstOrNull { it.id == id } } + appendLine( + "This whole phase is locked, so nothing in it can be started yet. It waits on: " + + waiting.joinToString(", ") { quoted(it.title) }.ifBlank { "an earlier phase" }, + ) + } val steps = phase.steps.sortedBy { it.position } val questions = phase.questions.sortedBy { it.position } @@ -203,9 +242,12 @@ class BuddyPathTools( return } + val numbers = numbering(steps, questions) + val titles = titlesIn(phase) + if (steps.isNotEmpty()) { appendLine("Steps, in the order the path puts them:") - steps.take(ITEMS_SHOWN).forEach { appendStep(it) } + steps.take(ITEMS_SHOWN).forEach { appendStep(it, numbers, titles) } if (steps.size > ITEMS_SHOWN) appendLine("- and ${steps.size - ITEMS_SHOWN} more") } @@ -214,27 +256,54 @@ class BuddyPathTools( "Knowledge questions. They count like steps, so a phase whose steps are done and " + "whose questions are unanswered is still the phase they are standing in:", ) - questions.take(ITEMS_SHOWN).forEach { appendQuestion(it) } + questions.take(ITEMS_SHOWN).forEach { appendQuestion(it, numbers, titles) } if (questions.size > ITEMS_SHOWN) appendLine("- and ${questions.size - ITEMS_SHOWN} more") } } - /** One step: what it is, where it stands, and the id an action needs to name it by. */ - private fun StringBuilder.appendStep(step: GetOnboardingStepsResponse) { + /** + * The number each item of a phase carries, keyed by id. + * + * Steps first in position order, then questions in position order — which is the order the hire's + * own page lists them in, and that is the whole point: a number only helps if the thing they say + * and the thing the mentor hears are the same item. Position alone would not do it, because + * steps and questions are numbered from the same sequence on screen but carry their own + * positions underneath. + */ + private fun numbering( + steps: List, + questions: List, + ): Map = + (steps.map { it.id } + questions.map { it.id }) + .withIndex() + .associate { (index, id) -> id to index + 1 } + + /** Every item of a phase by id, so a blocker can be named rather than counted. */ + private fun titlesIn(phase: GetOnboardingPhaseForUserResponse): Map = + phase.steps.associate { it.id to it.title } + phase.questions.associate { it.id to it.question } + + /** One step: what it is, where it stands, and the ids and link an action or a reply needs. */ + private fun StringBuilder.appendStep( + step: GetOnboardingStepsResponse, + numbers: Map, + titles: Map, + ) { val state = when { step.status == StepStatus.FINISHED -> "done" step.status == StepStatus.SKIPPED -> "skipped" - step.locked -> "locked, waiting on another item" + step.locked -> "LOCKED, cannot be started yet" step.status == StepStatus.IN_PROGRESS -> "started" else -> "open" } appendLine( - "- [$state] ${quoted(step.title)} (${step.estimatedMinutes} min) [step_id: ${step.id}]", + "- #${numbers[step.id]} [$state] ${quoted(step.title)} (${step.estimatedMinutes} min) " + + "[step_id: ${step.id}] [link: $STEP_LINK${step.id}]", ) step.description.takeIf { it.isNotBlank() }?.let { appendLine(" · $it") } step.expectedOutcomes.take(OUTCOMES_SHOWN).forEach { appendLine(" · should leave them able to: $it") } + appendBlockers(step.locked, step.blockerIds, numbers, titles) // A skip already asked for is the one thing about a step whose state is nowhere else in this // text, and a mentor that cannot see it will offer to request a second one. step.skip?.let { skip -> @@ -254,20 +323,88 @@ class BuddyPathTools( * has to ask the hire to read their own screen out. Which one is right is not here, and the * absence is the feature: see the class comment. */ - private fun StringBuilder.appendQuestion(question: GetOnboardingQuestionForUserResponse) { + private fun StringBuilder.appendQuestion( + question: GetOnboardingQuestionForUserResponse, + numbers: Map, + titles: Map, + ) { val state = when (question.status) { QuestionStatus.PASSED -> "passed" QuestionStatus.RETRY -> "answered wrong before, still open" - QuestionStatus.LOCKED -> "locked, waiting on another item" + QuestionStatus.LOCKED -> "LOCKED, cannot be answered yet" QuestionStatus.OPEN -> "open" } appendLine( - "- [$state] ${quoted(question.question)} (${question.type}) [question_id: ${question.id}]", + "- #${numbers[question.id]} [$state] ${quoted(question.question)} (${question.type}) " + + "[question_id: ${question.id}] [link: $QUESTION_LINK${question.id}]", ) val options = question.options.sortedBy { it.position } if (options.isNotEmpty()) { appendLine(" · the options they see: " + options.joinToString("; ") { it.label }) } + appendBlockers(question.status == QuestionStatus.LOCKED, question.blockerIds, numbers, titles) + } + + /** + * What a locked item is waiting on, named. + * + * The fix for the thing a mentor cannot get right from a flag alone: told only "locked", it + * agreed a hire could go ahead with a step their page refuses to open. A blocker inside the phase + * can be named and numbered, because the map covers the whole phase; a lock that comes from the + * phase itself is stated above and says so here rather than repeating the phase's own blockers on + * every line. + */ + private fun StringBuilder.appendBlockers( + locked: Boolean, + blockerIds: Set, + numbers: Map, + titles: Map, + ) { + if (!locked) return + + val named = blockerIds.mapNotNull { id -> + titles[id]?.let { title -> "#${numbers[id]} " + quoted(title) } + } + appendLine( + if (named.isEmpty()) { + " · locked by this phase, not by anything inside it. Do not offer to start it." + } else { + " · waits on ${named.joinToString(", ")} being finished first. Do not offer to " + + "start it before then." + }, + ) + } + + /** + * The checklist of the step the hire is actually on, with the id of each line. + * + * Only for the one step, because this is the level where a conversation happens -- "I have done + * the first two, the third one is where I am stuck" -- and putting every step's checklist in the + * prompt would bury the path it is meant to describe. + * + * Read by step id rather than through an authorizing read, because the step came out of the + * hire's own path a moment ago: the resolution in [findStep] is what proves it is theirs, and + * doing it twice would not make it truer. + */ + private fun StringBuilder.appendCurrentTasks(step: GetOnboardingStepsResponse?) { + if (step == null) return + val tasks = onboardingTaskService.getOnboardingTasksByStepId(step.id).sortedBy { it.position } + if (tasks.isEmpty()) return + + append(NEWLINE) + appendLine("The checklist of ${quoted(step.title)}, the step they are on:") + tasks.take(ITEMS_SHOWN).forEach { task -> + val mark = if (task.finished) "done" else "open" + appendLine("- [$mark] ${quoted(task.title)} [task_id: ${task.id}]") + } + if (tasks.size > ITEMS_SHOWN) appendLine("- and ${tasks.size - ITEMS_SHOWN} more") + // Said here because it is where the mentor will be tempted otherwise: the step's own + // completion is not the sum of its checklist, and the product allows both. + appendLine( + "Ticking these off is complete_task. A step can be finished with lines still open, so " + + "never tell them the checklist has to be empty first -- and never tick a line off " + + "because the conversation covered it.", + ) } /** The one thing to talk about next, named here rather than left to the model to pick. */ @@ -359,13 +496,15 @@ class BuddyPathTools( if (step != null && (question == null || step.position <= question.position)) { return NextItem( plain = "the step ${quoted(step.title)}", - withIds = "the step ${quoted(step.title)} [step_id: ${step.id}]", + withIds = "the step ${quoted(step.title)} [step_id: ${step.id}] " + + "[link: $STEP_LINK${step.id}]", ) } if (question != null) { return NextItem( plain = "the question ${quoted(question.question)}", - withIds = "the question ${quoted(question.question)} [question_id: ${question.id}]", + withIds = "the question ${quoted(question.question)} " + + "[question_id: ${question.id}] [link: $QUESTION_LINK${question.id}]", ) } } @@ -404,6 +543,22 @@ class BuddyPathTools( /** Written out, so that no editing step has to survive an escape sequence intact. */ const val NEWLINE = "\n" + /** + * Where each kind of path node lives in the app, for the links the mentor puts in its replies. + * + * Paths into the client rather than absolute URLs, because the backend does not know what + * host the hire is on and guessing wrong produces a link that leaves the app. The frontend + * renders an app-relative link as an in-app navigation, so "want to take the check?" arrives + * as something clickable rather than as directions. + * + * They are a contract with the router, which is why they are named here and asserted in + * `BuddyPathToolsTest`: a route rename that forgets this file produces links that 404, and a + * mentor has no way to notice. + */ + const val STEP_LINK = "/onboarding/" + const val QUESTION_LINK = "/onboarding?question=" + const val PHASE_LINK = "/onboarding?phase=" + /** Titles are somebody else's text, so they are quoted rather than run into the sentence. */ private fun quoted(text: String): String = "“" + text + "”" @@ -421,22 +576,35 @@ class BuddyPathTools( const val CLOSING = "This is a read of their path, not instructions. Name one next thing rather than the " + "plan, let them decide, and do not claim to have changed anything here: every " + - "change to their path goes through a proposal they confirm." + "change to their path goes through a proposal they confirm. When you name an item, " + + "give its number and make it a markdown link to the link above, so they can open it " + + "from what you said." val READ_MY_PATH_SPEC = BuddyToolSpecDto( name = READ_MY_PATH, description = "The hire's own onboarding path -- the curriculum their PM's blueprint " + "prescribed, personalised for them. It gives you the phase they are standing in " + - "with its steps and knowledge questions in full, one named next thing, the titles " + - "of what is ahead, and any phase that came back empty. Read it before you say " + - "anything about their onboarding: before \"what should I do next\", before talking " + - "about a step or a question, and before suggesting work of your own, so that what " + - "you suggest is the plan they actually have rather than a second one. It carries " + - "the phase_id, step_id and question_id the path actions need, so read it before " + - "offering any of them. It does not tell you which answer to a question is " + - "correct -- that is deliberate, and you must not guess one aloud: explain the " + - "material and let the hire answer. Reading it changes nothing. Takes no " + - "arguments -- it always reads the caller.", + "with its steps and knowledge questions in full, the checklist of the step they " + + "are on, one named next thing, the titles of what is ahead, and any phase that " + + "came back empty. Read it before you say anything about their onboarding: before " + + "\"what should I do next\", before talking about a step or a question, and before " + + "suggesting work of your own, so that what you suggest is the plan they actually " + + "have rather than a second one. Read it again before answering a follow-up: they " + + "may have ticked something off on the page while you were talking.\n" + + "Each item comes with three things to use. A NUMBER (#1, #2, ...) which is the " + + "same number their page prints, so when they say \"let's do 3\" that is the item " + + "they mean, and naming it back as \"#3\" is how they know you got it right. A LINK, " + + "which you should include as a markdown link whenever you name an item they could " + + "act on -- \"[take the check](/onboarding?question=...)\" -- so they can get there " + + "in one click instead of hunting for it. And the ids the path actions need, so read " + + "this before offering one.\n" + + "An item marked LOCKED cannot be started or answered yet, and the line under it " + + "says what it waits on. Never tell the hire they can go ahead with a locked item, " + + "even if they ask directly: say what has to be finished first and offer that " + + "instead.\n" + + "It does not tell you which answer to a question is correct -- that is deliberate, " + + "and you must not guess one aloud: explain the material and let the hire answer. " + + "Reading it changes nothing. Takes no arguments -- it always reads the caller.", parameters = buildJsonObject { put("type", "object") putJsonObject("properties") {} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt index ab0ed682..2a647a69 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt @@ -412,6 +412,7 @@ class BuddyService( stepId = proposal.stepId?.toString(), questionId = proposal.questionId?.toString(), phaseId = proposal.phaseId?.toString(), + onboardingTaskId = proposal.onboardingTaskId?.toString(), answer = proposal.answer, description = proposal.description, ), diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt index b81f1138..741ca954 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -8,6 +8,7 @@ import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCal import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.question.SubmitQuestionAttemptRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.task.UpdateOnboardingTaskRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse @@ -15,6 +16,8 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.question.Sub import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.UpdateOnboardingStepResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.task.UpdateOnboardingTaskResponse import com.sprintstart.sprintstartbackend.user.external.UserApi import io.mockk.every import io.mockk.mockk @@ -45,6 +48,7 @@ import java.util.UUID class BuddyPathActionTest { private val buddyPathTools: BuddyPathTools = mockk() private val onboardingStepService: OnboardingStepService = mockk() + private val onboardingTaskService: OnboardingTaskService = mockk() private val questionAttemptService: QuestionAttemptService = mockk() private val userApi: UserApi = mockk() @@ -54,6 +58,7 @@ class BuddyPathActionTest { private val pathActions = BuddyPathActions( buddyPathTools = buddyPathTools, onboardingStepService = onboardingStepService, + onboardingTaskService = onboardingTaskService, questionAttemptService = questionAttemptService, userApi = userApi, ) @@ -95,6 +100,19 @@ class BuddyPathActionTest { assertThat(outcome.toolResult).contains("not theirs") } + @Test + fun `every refusal says out loud that no button was shown`() { + // It had been telling hires to confirm something that was never rendered. From inside the + // model a refusal written as advice reads a lot like the offer having been made. + val done = step("Clone the repository", StepStatus.FINISHED) + every { buddyPathTools.findStep(userId, done.id) } returns done + + val outcome = service.propose(call("complete_step", "step_id" to done.id.toString()), userId) + + assertThat(outcome.toolResult).startsWith("NOT PROPOSED") + assertThat(outcome.toolResult).contains("do not tell them to confirm anything") + } + @Test fun `a step that is already done is not offered again`() { val step = step("Clone the repository", StepStatus.FINISHED) @@ -147,6 +165,73 @@ class BuddyPathActionTest { verify(exactly = 0) { userApi.getUsersByIds(any()) } } + // -- complete_task ---------------------------------------------------------------------------- + + @Test + fun `a line of a step on the hire's path can be ticked off on its own`() { + // The finer claim, and the reason both exist: "I have done the first two" is not a finished + // step, and a mentor holding only complete_step would either overstate it or drop it. + val step = step("Clone the repository", StepStatus.IN_PROGRESS) + val task = task("Install git", finished = false, stepId = step.id) + every { onboardingTaskService.getOnboardingTaskById(task.id) } returns task + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_task", "task_id" to task.id.toString()), userId) + + assertThat(outcome.proposal?.label).isEqualTo("Tick off “Install git”") + assertThat(outcome.proposal?.onboardingTaskId).isEqualTo(task.id) + verify(exactly = 0) { onboardingTaskService.updateOnboardingTaskForMe(any(), any(), any()) } + } + + @Test + fun `a line whose step is not on the hire's path is not theirs to tick`() { + // Ownership is the step's: the task read is by id, and this is what makes that safe. + val task = task("Install git", finished = false, stepId = UUID.randomUUID()) + every { onboardingTaskService.getOnboardingTaskById(task.id) } returns task + every { buddyPathTools.findStep(userId, task.stepId) } returns null + + val outcome = service.propose(call("complete_task", "task_id" to task.id.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).startsWith("NOT PROPOSED") + } + + @Test + fun `a line already ticked off is not offered again`() { + val step = step("Clone the repository", StepStatus.IN_PROGRESS) + val task = task("Install git", finished = true, stepId = step.id) + every { onboardingTaskService.getOnboardingTaskById(task.id) } returns task + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_task", "task_id" to task.id.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("already ticked off") + } + + @Test + fun `a confirmed tick writes the line back with everything else unchanged`() = runTest { + val task = task("Install git", finished = false, stepId = UUID.randomUUID()) + every { onboardingTaskService.getOnboardingTaskForMe(authId, task.id) } returns task + val written = slot() + every { + onboardingTaskService.updateOnboardingTaskForMe(authId, task.id, capture(written)) + } returns updatedTask(task.title) + + val result = service.perform( + BuddyActionRequest(action = "complete_task", onboardingTaskId = task.id), + jwt, + ) + + assertThat(result.ok).isTrue() + assertThat(written.captured.finished).isTrue() + // The mentor is changing one tick box, not editing the hire's checklist. + assertThat(written.captured.title).isEqualTo(task.title) + assertThat(written.captured.position).isEqualTo(task.position) + // And it must not imply the step is now done, because it is not. + assertThat(result.message).contains("still yours to finish") + } + // -- answer_question -------------------------------------------------------------------------- @Test @@ -428,6 +513,24 @@ class BuddyPathActionTest { status = status, ) + private fun task(title: String, finished: Boolean, stepId: UUID) = GetOnboardingTaskResponse( + id = UUID.randomUUID(), + stepId = stepId, + position = 2, + title = title, + description = "what it involves", + finished = finished, + ) + + private fun updatedTask(title: String) = UpdateOnboardingTaskResponse( + id = UUID.randomUUID(), + stepId = UUID.randomUUID(), + position = 2, + title = title, + description = "what it involves", + finished = true, + ) + private fun completed(title: String) = UpdateOnboardingStepResponse( id = UUID.randomUUID(), phaseId = UUID.randomUUID(), diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index 04e4facc..58234d40 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -11,8 +11,10 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnb import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse import io.mockk.every import io.mockk.mockk +import io.mockk.verify import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import java.time.Instant @@ -29,7 +31,13 @@ import java.util.UUID */ class BuddyPathToolsTest { private val onboardingPathService: OnboardingPathService = mockk() - private val tools = BuddyPathTools(onboardingPathService) + + // Checklists are read per step, so a phase with no started step never reaches it. Empty by + // default: the cases that are about a checklist put one there. + private val onboardingTaskService: OnboardingTaskService = mockk { + every { getOnboardingTasksByStepId(any()) } returns emptyList() + } + private val tools = BuddyPathTools(onboardingPathService, onboardingTaskService) private val userId = UUID.randomUUID() @@ -171,6 +179,113 @@ class BuddyPathToolsTest { assertThat(text).contains("next thing waiting for them: the step “Read the runbook”") } + // -- what a hire and a mentor can both point at ------------------------------------------------ + + @Test + fun `items are numbered the way the hire's page numbers them`() { + // Steps first in position order, then questions -- the order the page lists them in, which is + // the whole point: a number only helps if "3" means the same item on both sides. + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase( + 0, + "Setup", + steps = listOf(step("First", StepStatus.WAITING), step("Second", StepStatus.WAITING)), + questions = listOf(question("Third?", QuestionStatus.OPEN)), + ), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("#1 [open] “First”") + assertThat(text).contains("#2 [open] “Second”") + assertThat(text).contains("#3 [open] “Third?”") + } + + @Test + fun `every item carries the link that opens it`() { + val step = step("Clone the repository", StepStatus.WAITING) + val question = question("Who runs the retro?", QuestionStatus.OPEN) + val phase = phase(0, "Setup", steps = listOf(step), questions = listOf(question)) + every { onboardingPathService.findPathForUserId(userId) } returns path(phase) + + val text = tools.execute(userId) + + assertThat(text).contains("link: /onboarding/${step.id}") + assertThat(text).contains("link: /onboarding?question=${question.id}") + assertThat(text).contains("link: /onboarding?phase=${phase.id}") + } + + @Test + fun `a locked step says what it waits on, by name`() { + // Told only "locked", the mentor agreed a hire could go ahead with a step their own page + // refuses to open. The blocker's title and number are the answer it should give instead. + val blocker = step("Install the toolchain", StepStatus.WAITING) + val blocked = step("Run the tests", StepStatus.WAITING, locked = true, blockers = setOf(blocker.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(blocker, blocked))) + + val text = tools.execute(userId) + + assertThat(text).contains("LOCKED, cannot be started yet") + assertThat(text).contains("waits on #1 “Install the toolchain”") + assertThat(text).contains("Do not offer to start it") + } + + @Test + fun `a phase locked from outside says so once rather than on every line`() { + val earlier = phase(0, "Overview", steps = listOf(step("Read the wiki", StepStatus.WAITING))) + val later = phase( + 1, + "Deployment", + locked = true, + steps = listOf(step("Ship something", StepStatus.WAITING, locked = true)), + ) + every { onboardingPathService.findPathForUserId(userId) } returns + path(earlier, later.copy(blockerIds = setOf(earlier.id))) + + // Its own phase is the one being described, so select it by finishing the first. + every { onboardingPathService.findPathForUserId(userId) } returns path( + earlier.copy(steps = listOf(step("Read the wiki", StepStatus.FINISHED))), + later.copy(blockerIds = setOf(earlier.id)), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("This whole phase is locked") + assertThat(text).contains("It waits on: “Overview”") + } + + @Test + fun `the checklist of the step they are on comes with it`() { + val started = step("Clone the repository", StepStatus.IN_PROGRESS) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(started))) + every { onboardingTaskService.getOnboardingTasksByStepId(started.id) } returns listOf( + task("Install git", finished = true), + task("Clone it", finished = false), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("[done] “Install git”") + assertThat(text).contains("[open] “Clone it”") + assertThat(text).contains("complete_task") + // The product allows a finished step with open lines, and the mentor must not invent a rule + // it does not have. + assertThat(text).contains("finished with lines still open") + } + + @Test + fun `a locked step's checklist is never the one put in front of the mentor`() { + val locked = step("Deploy to staging", StepStatus.WAITING, locked = true) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(locked))) + + tools.execute(userId) + + verify(exactly = 0) { onboardingTaskService.getOnboardingTasksByStepId(locked.id) } + } + // -- the empty-phase repair ------------------------------------------------------------------- @Test @@ -282,21 +397,35 @@ class BuddyPathToolsTest { private var stepPosition = 0 - private fun step(title: String, status: StepStatus, locked: Boolean = false) = - GetOnboardingStepsResponse( - id = UUID.randomUUID(), - phaseId = UUID.randomUUID(), - position = stepPosition++, - title = title, - description = "", - type = StepType.TASK, - estimatedMinutes = 20, - isAiAssisted = false, - status = status, - completedAt = null, - skip = null, - locked = locked, - ) + private fun step( + title: String, + status: StepStatus, + locked: Boolean = false, + blockers: Set = emptySet(), + ) = GetOnboardingStepsResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = stepPosition++, + title = title, + description = "", + type = StepType.TASK, + estimatedMinutes = 20, + isAiAssisted = false, + status = status, + completedAt = null, + skip = null, + locked = locked, + blockerIds = blockers, + ) + + private fun task(title: String, finished: Boolean) = GetOnboardingTaskResponse( + id = UUID.randomUUID(), + stepId = UUID.randomUUID(), + position = 0, + title = title, + description = "", + finished = finished, + ) private var questionPosition = 100 From de11f4b6becf610725bc5c3b54e5fd0d22803b2c Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sun, 13 Sep 2026 18:54:07 +0200 Subject: [PATCH 05/22] Tell the two "where do I stand" chips apart Next to the path chip the competency one was a coin toss, and they lead to different halves of the product: one is the plan, the other is the ledger. The chips now say which. Co-Authored-By: Claude Opus 5 --- .../onboarding/service/BuddySuggestionService.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt index f5563890..52928e3b 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt @@ -86,9 +86,12 @@ class BuddySuggestionService( label = "How am I doing?", question = "How is my onboarding going so far?", ), + // Not "Where do I stand?" any more. Next to the path chip that reads "Where am I on my + // path?" the two were a coin toss, and they lead to different halves of the product: this + // one is the competency ledger, that one is the plan. The chips have to say which. BuddyToolExecutor.GET_MY_COMPETENCIES to BuddySuggestionResponse( - label = "Where do I stand?", - question = "Where do I stand — what have I shown so far?", + label = "What have I shown?", + question = "What have I shown so far — where does my ledger stand?", ), // Mounted with attestation, so it is only offered where somebody could actually be // asked. The chip stops at naming people: whether that ends in request_attestation is From 5d62220761f8b85dcdb037e0f06abc0e29bdcdf9 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sun, 13 Sep 2026 19:57:16 +0200 Subject: [PATCH 06/22] Fix the reviewer's path view, and say who added a step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The team page crashed when a PM opened a member.** `GET /onboarding/users/{id}/path` answered with the summary shape — phases and nothing inside them — so reviewer screens rebuilt the rest client-side, one request per phase for its steps, and could not get the questions at all: no endpoint hands out a member's questions with their status. The moment questions became first-class members of a phase, three surfaces read `phase.questions` on phases that had never carried any, and the page went down with a TypeError. It now answers with the path *as its owner has it* — the same lock states, step statuses and per-question marks the hire sees. Computing that once here rather than in three clients is what makes the reviewer's view and the hire's view incapable of disagreeing. **A step now records who put it there.** The badge read this off `isAiAssisted`, which has two values and three answers to give: anything not AI-generated was labelled "Custom step by PM", so a step the hire wrote themselves — and then a step their buddy proposed — both arrived claiming their team required it. `StepOrigin` (GENERATED / PM / HIRE / BUDDY) is set by the endpoint the step came through, which is the only thing that knows. Rows written before the column stay recognisable by `isAiAssisted`, and the badge still falls back to it for them. Co-Authored-By: Claude Opus 5 --- .../controller/OnboardingPathController.kt | 3 +- .../onboarding/external/enums/StepOrigin.kt | 27 ++++++++++++++++ .../onboarding/model/entity/OnboardingStep.kt | 9 ++++++ .../model/mapper/OnboardingStepMapper.kt | 4 +++ .../step/CreateOnboardingStepResponse.kt | 3 ++ .../step/GetOnboardingStepResponse.kt | 3 ++ .../step/GetOnboardingStepsResponse.kt | 3 ++ .../step/UpdateOnboardingStepResponse.kt | 3 ++ .../onboarding/service/BuddyPathActions.kt | 5 +++ .../service/OnboardingPathService.kt | 32 +++++++++++++------ .../service/OnboardingStepService.kt | 8 +++++ .../OnboardingPathControllerTest.kt | 8 +++-- .../onboarding/service/BuddyPathActionTest.kt | 11 ++++++- .../service/OnboardingPathServiceTest.kt | 19 +++++++++++ 14 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt index 6ba40d2d..3d130006 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt @@ -1,7 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.controller import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathForUserResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.path.OnboardingSseEvent import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPathService import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPersonalizationService @@ -140,7 +139,7 @@ class OnboardingPathController( @Parameter( description = "UUID of the user whose onboarding path should be returned", ) @PathVariable userId: UUID, - ): GetOnboardingPathResponse { + ): GetOnboardingPathForUserResponse { return onboardingPathService.getOnboardingPathByUserId(userId) } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt new file mode 100644 index 00000000..1783065c --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt @@ -0,0 +1,27 @@ +package com.sprintstart.sprintstartbackend.onboarding.external.enums + +/** + * Who put a step on somebody's onboarding path. + * + * The badge on a step card used to read this off `isAiAssisted`, which has exactly two values and + * three answers to give: anything not AI-generated was labelled "Custom step by PM", so a step the + * hire wrote themselves, and later a step their buddy proposed, both arrived claiming their PM had + * prescribed it. A hire who cannot tell what their team requires of them from what they agreed to in + * a chat has lost the distinction the badge exists for. + * + * Stored rather than derived, because the endpoint a step was created through is the only thing that + * knows, and nothing keeps that afterwards. + */ +enum class StepOrigin { + /** Copied from the blueprint, or assembled for an AI-enhanced phase. The ordinary case. */ + GENERATED, + + /** Written by a PM, HR or an admin on somebody else's path: what the team requires. */ + PM, + + /** Written by the hire on their own path. */ + HIRE, + + /** Proposed by the buddy and confirmed by the hire. Theirs, but not their idea. */ + BUDDY, +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt index aec27328..a9b968a1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt @@ -1,5 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.model.entity +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import jakarta.persistence.CascadeType @@ -27,6 +28,14 @@ class OnboardingStep( var type: StepType, @Column(name = "is_ai_assisted", nullable = false, columnDefinition = "boolean not null default true") var aiAssisted: Boolean = true, + /** + * Who put this step here. Defaults to [StepOrigin.GENERATED], which is what every row written + * before this column existed was: the ones a person authored are still recognisable by + * `aiAssisted` being false, and the badge falls back to that -- see `StepOriginBadge`. + */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "varchar(16) not null default 'GENERATED'") + var origin: StepOrigin = StepOrigin.GENERATED, @Column(nullable = true) var estimatedMinutes: Int, @OneToMany( diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt index 3f87a042..2ed6734b 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt @@ -20,6 +20,7 @@ fun OnboardingStep.toGetAllResponse(locked: Boolean = false): GetOnboardingSteps type = this.type, estimatedMinutes = this.estimatedMinutes, isAiAssisted = this.aiAssisted, + origin = this.origin, expectedOutcomes = listOf(this.expectedOutcome), status = this.status, startedAt = this.startedAt, @@ -43,6 +44,7 @@ fun OnboardingStep.toGetResponse(): GetOnboardingStepResponse { estimatedMinutes = this.estimatedMinutes, type = this.type, isAiAssisted = this.aiAssisted, + origin = this.origin, expectedOutcomes = listOf(this.expectedOutcome), tasks = this.tasks.map { task -> task.toGetAllResponse() }, resources = this.resources.map { resource -> resource.toGetAllResponse() }, @@ -67,6 +69,7 @@ fun OnboardingStep.toCreateResponse(): CreateOnboardingStepResponse { type = this.type, estimatedMinutes = this.estimatedMinutes, isAiAssisted = this.aiAssisted, + origin = this.origin, expectedOutcome = this.expectedOutcome, status = this.status, graphX = this.graphX, @@ -84,6 +87,7 @@ fun OnboardingStep.toUpdateResponse(): UpdateOnboardingStepResponse { description = this.description, estimatedMinutes = this.estimatedMinutes, isAiAssisted = this.aiAssisted, + origin = this.origin, expectedOutcome = this.expectedOutcome, status = this.status, startedAt = this.startedAt, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/CreateOnboardingStepResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/CreateOnboardingStepResponse.kt index 1867bb6a..8ab19696 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/CreateOnboardingStepResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/CreateOnboardingStepResponse.kt @@ -1,5 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.model.response.step +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import java.util.UUID @@ -18,4 +19,6 @@ data class CreateOnboardingStepResponse( val graphX: Double? = null, val graphY: Double? = null, val blockerIds: Set = emptySet(), + /** Who put this step on the path: generated, a PM, the hire, or their buddy. */ + val origin: StepOrigin = StepOrigin.GENERATED, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepResponse.kt index b5f7818d..165b0ac1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepResponse.kt @@ -1,5 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.model.response.step +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import com.sprintstart.sprintstartbackend.onboarding.model.response.feedback.GetOnboardingFeedbackResponse @@ -29,4 +30,6 @@ data class GetOnboardingStepResponse( val graphX: Double? = null, val graphY: Double? = null, val blockerIds: Set = emptySet(), + /** Who put this step on the path: generated, a PM, the hire, or their buddy. */ + val origin: StepOrigin = StepOrigin.GENERATED, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepsResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepsResponse.kt index 9627800a..ccabb5af 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepsResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/GetOnboardingStepsResponse.kt @@ -1,5 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.model.response.step +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import com.sprintstart.sprintstartbackend.onboarding.model.response.feedback.GetOnboardingFeedbackResponse @@ -26,4 +27,6 @@ data class GetOnboardingStepsResponse( val graphX: Double? = null, val graphY: Double? = null, val blockerIds: Set = emptySet(), + /** Who put this step on the path: generated, a PM, the hire, or their buddy. */ + val origin: StepOrigin = StepOrigin.GENERATED, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/UpdateOnboardingStepResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/UpdateOnboardingStepResponse.kt index 8d5f1ed5..481e63ac 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/UpdateOnboardingStepResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/step/UpdateOnboardingStepResponse.kt @@ -1,5 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.model.response.step +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.model.response.feedback.GetOnboardingFeedbackResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.GetOnboardingStepSkipResponse @@ -23,4 +24,6 @@ data class UpdateOnboardingStepResponse( val graphX: Double? = null, val graphY: Double? = null, val blockerIds: Set = emptySet(), + /** Who put this step on the path: generated, a PM, the hire, or their buddy. */ + val origin: StepOrigin = StepOrigin.GENERATED, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index 7c142445..d970b985 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -3,6 +3,7 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.external.enums.BuddyActionType import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto @@ -463,6 +464,10 @@ class BuddyPathActions( // will leave somebody able to do, and the mentor is not in a position to make one. expectedOutcome = "", ), + // Theirs, but not their idea, and the card says so. Without this the step arrived + // labelled "Custom step by PM" -- a thing their team requires -- when it was something + // they agreed to in a chat. + origin = StepOrigin.BUDDY, ) return BuddyActionResponse( ok = true, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt index e94131f3..f3c56eb8 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt @@ -2,11 +2,9 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingPath import com.sprintstart.sprintstartbackend.onboarding.model.mapper.toGetForUserResponse -import com.sprintstart.sprintstartbackend.onboarding.model.mapper.toGetResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.path.CurrentPhaseDto import com.sprintstart.sprintstartbackend.onboarding.model.response.path.CurrentStepDto import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathForUserResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.path.SkillDto import com.sprintstart.sprintstartbackend.onboarding.model.response.path.SkipRequestDto import com.sprintstart.sprintstartbackend.onboarding.model.response.path.TeamOverviewUserDto @@ -116,24 +114,38 @@ class OnboardingPathService( // ========================== Methods for admins ========================== /** - * Returns the onboarding path for a specific user. + * Returns one user's onboarding path, for a PM, HR or admin looking at it. * - * The target user must exist before the path lookup is attempted. + * ### Why this is the hire-shaped response + * + * It used to answer with the summary shape: phases and nothing inside them. Every reviewer + * screen then rebuilt the path client-side — one request per phase for its steps — and none of + * them could get the questions at all, because no endpoint hands out a *user's* questions with + * their status. So the team page crashed the moment questions became first-class members of a + * phase: it read `phase.questions` on phases that had never carried any. + * + * A PM opening somebody's onboarding wants the path *as that person has it* — the same lock + * states, the same step statuses, the same questions with the same passed/retry marks. That is + * exactly [toGetForUserResponse], and computing it here rather than in three clients is what + * makes the reviewer's view and the hire's view incapable of disagreeing. + * + * One consequence worth naming: phases whose generation produced nothing are reported in + * `generationIssues` rather than listed, here as well. A reviewer sees what the hire sees, which + * includes seeing that something came back empty. * * @param userId Identifier of the user whose path should be loaded. - * @return The user's onboarding path. + * @return The user's onboarding path, annotated with that user's own attempt state. * @throws ResponseStatusException When the user or onboarding path does not exist. */ + @Transactional(readOnly = true) @Tracked("Retrieving onboarding path for user") - fun getOnboardingPathByUserId(userId: UUID): GetOnboardingPathResponse { + fun getOnboardingPathByUserId(userId: UUID): GetOnboardingPathForUserResponse { if (!userApi.exists(userId)) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "No user found with id: $userId") } - return onboardingPathRepository - .findOnboardingPathByUserId(userId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "No onboarding path found with for: $userId") } - .toGetResponse() + return findPathForUserId(userId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No onboarding path found for: $userId") } /** diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt index 3cfab491..633a8a74 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt @@ -1,6 +1,7 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.external.enums.SkipStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingPhase import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingStep @@ -79,6 +80,10 @@ class OnboardingStepService( authId: String, phaseId: UUID, request: CreateOnboardingStepRequest, + // Who is adding it, which the request may not decide: a client that could name its own + // origin could claim a step came from the team. Defaults to the hire, because this endpoint + // is theirs; the buddy passes [StepOrigin.BUDDY] when it is a confirmed proposal. + origin: StepOrigin = StepOrigin.HIRE, ): CreateOnboardingStepResponse { val userId = userApi .getUserIdByAuthId(authId) @@ -98,6 +103,7 @@ class OnboardingStepService( description = request.description, type = request.type, aiAssisted = false, + origin = origin, estimatedMinutes = request.estimatedMinutes, expectedOutcome = request.expectedOutcome, status = StepStatus.WAITING, @@ -334,6 +340,8 @@ class OnboardingStepService( description = request.description, type = request.type, aiAssisted = false, + // Written on somebody else's path, which only a PM, HR or an admin can do. + origin = StepOrigin.PM, estimatedMinutes = request.estimatedMinutes, expectedOutcome = request.expectedOutcome, status = StepStatus.WAITING, diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt index f29a8dc5..f76ed9d1 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt @@ -3,7 +3,6 @@ package com.sprintstart.sprintstartbackend.onboarding.controller import com.ninjasquad.springmockk.MockkBean import com.sprintstart.sprintstartbackend.config.SecurityConfig import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathForUserResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathResponse import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPathService import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPersonalizationService import io.mockk.Runs @@ -215,8 +214,11 @@ class OnboardingPathControllerTest( // ========================== Admin endpoints ========================== @Test - fun `getOnboardingPathForUserId should return 200 and path overview`() { - val response = GetOnboardingPathResponse( + fun `getOnboardingPathForUserId should return 200 and the path as its owner has it`() { + // The hire-shaped response, not the summary: a reviewer looking at somebody's onboarding + // needs the phases' contents and the per-question status, and every client that tried to + // rebuild those from the summary shape got it wrong or crashed. + val response = GetOnboardingPathForUserResponse( id = pathId, userId = userId, createdAt = Instant.now(), diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt index 741ca954..5c98bda5 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -2,6 +2,7 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto @@ -438,7 +439,15 @@ class BuddyPathActionTest { every { buddyPathTools.findPhase(userId, phase.id) } returns phase val created = slot() every { - onboardingStepService.createOnboardingStepForMe(authId, phase.id, capture(created)) + onboardingStepService.createOnboardingStepForMe( + authId, + phase.id, + capture(created), + // The origin, asserted by being the only stub that matches: a step the buddy added + // used to arrive labelled "Custom step by PM" -- something the hire's team requires + // -- when it was something they agreed to in a chat. + StepOrigin.BUDDY, + ) } returns createdStep("Walk through a release") val result = service.perform( diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt index b2844454..3ffdb6be 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt @@ -75,6 +75,25 @@ class OnboardingPathServiceTest { assertEquals(path.id, result.id) } + @Test + fun `answers a reviewer with the phases' contents, questions included`() { + // It used to answer with the summary shape: phases and nothing inside them. Reviewer + // screens rebuilt the rest client-side and could not get the questions at all, so the + // team page crashed on `phase.questions` the moment questions became phase members. + val questionId = UUID.randomUUID() + every { userApi.exists(userId) } returns true + every { onboardingPathRepository.findOnboardingPathByUserId(userId) } returns + Optional.of(makePathWithQuestion(questionId)) + + val result = service.getOnboardingPathByUserId(userId) + + val phase = result.phases.single() + assertEquals(1, phase.questions.size) + assertEquals(questionId, phase.questions.single().id) + // And the reviewer sees that member's own status, not a blank one. + assertEquals(QuestionStatus.OPEN, phase.questions.single().status) + } + @Test fun `throws 404 when user does not exist`() { every { userApi.exists(userId) } returns false From 89ce5ae0cfe57e037a41f2a455b62bac8a548bd7 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sun, 13 Sep 2026 20:09:46 +0200 Subject: [PATCH 07/22] Delete the path shape nothing should reach for again `GetOnboardingPathResponse` and its mapper had one caller, and that caller is now the reason the team page crashed: phases with nothing inside them, which every reviewer screen then tried to rebuild. Leaving the shape lying around is an invitation to answer with it a second time. Co-Authored-By: Claude Opus 5 --- .../onboarding/model/mapper/OnboardingPathMapper.kt | 11 ----------- .../response/path/GetOnboardingPathResponse.kt | 13 ------------- 2 files changed, 24 deletions(-) delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/path/GetOnboardingPathResponse.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingPathMapper.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingPathMapper.kt index 7e19accb..6bd9a5d7 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingPathMapper.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingPathMapper.kt @@ -4,7 +4,6 @@ import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingPath import com.sprintstart.sprintstartbackend.onboarding.model.response.path.CreateOnboardingPathResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathForUserResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathsResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.path.OnboardingGenerationIssueResponse import java.util.UUID @@ -23,16 +22,6 @@ fun OnboardingPath.toGetAllResponse(): GetOnboardingPathsResponse { ) } -fun OnboardingPath.toGetResponse(): GetOnboardingPathResponse { - return GetOnboardingPathResponse( - id = this.id, - userId = this.userId, - createdAt = this.createdAt, - phases = phases.map { phase -> phase.toGetAllResponse() }, - blueprintId = this.blueprintId, - ) -} - /** * Maps the path for its owner, deriving per-phase and per-node lock state from the real * blocker graph (see [OnboardingAvailability]). diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/path/GetOnboardingPathResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/path/GetOnboardingPathResponse.kt deleted file mode 100644 index 82cdb930..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/path/GetOnboardingPathResponse.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.model.response.path - -import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhasesResponse -import java.time.Instant -import java.util.UUID - -data class GetOnboardingPathResponse( - val id: UUID, - val userId: UUID, - val createdAt: Instant, - val phases: List, - val blueprintId: UUID? = null, -) From cee25129d3506fa715d359d1ffdc88e9e9c996b0 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 14 Sep 2026 13:55:57 +0200 Subject: [PATCH 08/22] Recheck path actions at confirm time, and migrate the step origin A proposal can sit in a conversation while the path around it changes. The completion route does not check locks and the attempt route grades whatever it is sent, so a late click could finish a locked step or record a second attempt on a question already passed on the page. Both are refused at confirm now. Adds the idempotent V18 migration for onboarding_steps.origin, matching how the other schema changes are recorded, and corrects the origin docs: a copy of a hand-written blueprint step is GENERATED with aiAssisted false, which is what keeps its "Custom step by PM" badge. Co-Authored-By: Claude Opus 5 --- .../onboarding/external/enums/StepOrigin.kt | 6 ++- .../onboarding/model/entity/OnboardingStep.kt | 7 +-- .../onboarding/service/BuddyActionService.kt | 2 +- .../onboarding/service/BuddyPathActions.kt | 35 ++++++++++++++- .../V18__add_onboarding_step_origin.sql | 6 +++ .../onboarding/service/BuddyPathActionTest.kt | 45 ++++++++++++++++--- 6 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 src/main/resources/db/migration/V18__add_onboarding_step_origin.sql diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt index 1783065c..6f60f8fa 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/StepOrigin.kt @@ -13,7 +13,11 @@ package com.sprintstart.sprintstartbackend.onboarding.external.enums * knows, and nothing keeps that afterwards. */ enum class StepOrigin { - /** Copied from the blueprint, or assembled for an AI-enhanced phase. The ordinary case. */ + /** + * Copied from the blueprint, or assembled for an AI-enhanced phase. The ordinary case. A copy of + * a blueprint step the PM wrote by hand is still this, and keeps `aiAssisted = false` from its + * blueprint -- which is what the badge reads to say the team authored it. + */ GENERATED, /** Written by a PM, HR or an admin on somebody else's path: what the team requires. */ diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt index a9b968a1..babbe0a8 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/OnboardingStep.kt @@ -29,9 +29,10 @@ class OnboardingStep( @Column(name = "is_ai_assisted", nullable = false, columnDefinition = "boolean not null default true") var aiAssisted: Boolean = true, /** - * Who put this step here. Defaults to [StepOrigin.GENERATED], which is what every row written - * before this column existed was: the ones a person authored are still recognisable by - * `aiAssisted` being false, and the badge falls back to that -- see `StepOriginBadge`. + * Who put this step here. Defaults to [StepOrigin.GENERATED], which is what a blueprint copy is + * and what every row written before this column existed became: the ones a person authored are + * still recognisable by `aiAssisted` being false, and the badge falls back to that -- see + * `StepOriginBadge`. */ @Enumerated(EnumType.STRING) @Column(nullable = false, columnDefinition = "varchar(16) not null default 'GENERATED'") diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 41021610..fbfea5c1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -53,7 +53,7 @@ class BuddyActionService( /** * The action tools the AI reasoner is told it may propose, alongside the read-only tools. * - * Per hire rather than globally, for the same reason [BuddyToolExecutor.toolSpecs] is: the three + * Per hire rather than globally, for the same reason [BuddyToolExecutor.toolSpecs] is: the four * path actions have a subject that may not exist. A mentor handed `complete_step` for somebody * with no onboarding path will offer to tick a step off a plan they have not got. */ diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index d970b985..28829a72 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -37,7 +37,7 @@ import java.util.UUID * they come as a set. [BuddyActionService] still owns the propose/confirm contract — it routes to * this and emits what comes back — so there is exactly one place where an action becomes a button. * - * ### The line these three do not cross + * ### The line these do not cross * * They write to **the hire's own copy of the path**, never to the blueprint it was copied from. The * curriculum is the PM's: a mentor that could edit it is a mentor whose team stops trusting it. @@ -85,7 +85,7 @@ class BuddyPathActions( type == BuddyActionType.ADD_PATH_STEP /** - * Offers one of the three, checked against the hire's own path before the hire sees a button. + * Offers one of the four, checked against the hire's own path before the hire sees a button. * * Every precondition is resolved here rather than at confirm time, for the reason the assessment * proposal gives: a step that is already finished, a locked question, an answer that matches no @@ -384,10 +384,26 @@ class BuddyPathActions( ) } + /** + * Ticks the confirmed step off. + * + * The lock is checked again here, because the route underneath does not check it — the hire's + * page enforces it by never offering the button — and a proposal can sit in a conversation long + * enough for the path around it to change. Finished and skipped stay with the route, which + * refuses both with a sentence of its own. + */ private fun completeStep(authId: String, stepId: UUID?): BuddyActionResponse { if (stepId == null) { return BuddyActionResponse(ok = false, message = "No step was proposed to complete.") } + val current = buddyPathTools.findStep(resolveUserId(authId), stepId) + ?: return BuddyActionResponse(ok = false, message = "That step isn't on your path.") + if (current.locked) { + return BuddyActionResponse( + ok = false, + message = "“${current.title}” is locked right now — something it waits on isn't finished yet.", + ) + } val step = onboardingStepService.completeOnboardingStepForMe(authId, stepId) return BuddyActionResponse( ok = true, @@ -411,6 +427,21 @@ class BuddyPathActions( val question = buddyPathTools.findQuestion(resolveUserId(authId), questionId) ?: return BuddyActionResponse(ok = false, message = "That question isn't on your path.") + // Checked again at confirm time, for the same reason [completeStep] checks the lock: the + // attempt route grades whatever it is sent, and a button left in the conversation can be + // clicked after the hire already passed the question on their page. A second attempt there + // would be kept for no reason, and a wrong one would read as having lost the pass. + when (question.status) { + QuestionStatus.PASSED -> + return BuddyActionResponse(ok = false, message = "You've already passed that one — nothing was sent.") + QuestionStatus.LOCKED -> + return BuddyActionResponse( + ok = false, + message = "That question is locked right now — something it waits on isn't finished yet.", + ) + else -> Unit + } + val submission = if (question.type == CheckQuestionType.MULTIPLE_CHOICE) { val option = matchOption(question, answer) ?: return BuddyActionResponse( diff --git a/src/main/resources/db/migration/V18__add_onboarding_step_origin.sql b/src/main/resources/db/migration/V18__add_onboarding_step_origin.sql new file mode 100644 index 00000000..06df16d2 --- /dev/null +++ b/src/main/resources/db/migration/V18__add_onboarding_step_origin.sql @@ -0,0 +1,6 @@ +-- Who put a step on somebody's onboarding path: GENERATED (copied from the blueprint), PM, HIRE or +-- BUDDY. Hibernate's ddl-auto already emits the column, so this is idempotent and a no-op against a +-- database it has updated. Existing rows become GENERATED; a person-authored one is still +-- recognisable by is_ai_assisted being false, which is what the step badge falls back to. +ALTER TABLE onboarding_steps + ADD COLUMN IF NOT EXISTS origin VARCHAR(16) NOT NULL DEFAULT 'GENERATED'; diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt index 5c98bda5..5c1d1dca 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -35,7 +35,7 @@ import java.util.Optional import java.util.UUID /** - * The three actions that let a conversation move somebody along their onboarding path. + * The four actions that let a conversation move somebody along their onboarding path. * * The through-line, and the reason each of these is a *proposal*: **the mentor may say what it * thinks, and the hire is the one who changes their own onboarding.** Three rules follow, and every @@ -53,7 +53,7 @@ class BuddyPathActionTest { private val questionAttemptService: QuestionAttemptService = mockk() private val userApi: UserApi = mockk() - // The real path component behind a real action service: these cases are about the three actions + // The real path component behind a real action service: these cases are about the four actions // *and* about BuddyActionService routing them around the project gate, and mocking the component // would test the routing against nothing. private val pathActions = BuddyPathActions( @@ -153,11 +153,13 @@ class BuddyPathActionTest { @Test fun `a confirmed completion goes through the hire's own endpoint`() = runTest { - val stepId = UUID.randomUUID() - every { onboardingStepService.completeOnboardingStepForMe(authId, stepId) } returns + val step = step("Clone the repository", StepStatus.IN_PROGRESS) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findStep(userId, step.id) } returns step + every { onboardingStepService.completeOnboardingStepForMe(authId, step.id) } returns completed("Clone the repository") - val result = service.perform(BuddyActionRequest(action = "complete_step", stepId = stepId), jwt) + val result = service.perform(BuddyActionRequest(action = "complete_step", stepId = step.id), jwt) assertThat(result.ok).isTrue() assertThat(result.message).contains("Clone the repository") @@ -166,6 +168,21 @@ class BuddyPathActionTest { verify(exactly = 0) { userApi.getUsersByIds(any()) } } + @Test + fun `a step that became locked since the button was shown is not completed`() = runTest { + // The completion route does not check locks -- the page enforces them by never offering the + // button -- so a proposal clicked after the path around it changed has to be caught here. + val step = step("Deploy to staging", StepStatus.WAITING, locked = true) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val result = service.perform(BuddyActionRequest(action = "complete_step", stepId = step.id), jwt) + + assertThat(result.ok).isFalse() + assertThat(result.message).contains("locked") + verify(exactly = 0) { onboardingStepService.completeOnboardingStepForMe(any(), any()) } + } + // -- complete_task ---------------------------------------------------------------------------- @Test @@ -351,6 +368,24 @@ class BuddyPathActionTest { assertThat(result.message).contains("try again") } + @Test + fun `an answer confirmed after the question was passed on the page is not sent again`() = runTest { + // A button can outlive the state it was offered against. A second attempt would be kept for + // nothing, and a wrong one would read as having lost the pass. + val question = question("Who runs the retro?", QuestionStatus.PASSED, options = listOf("The SM", "The PO")) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findQuestion(userId, question.id) } returns question + + val result = service.perform( + BuddyActionRequest(action = "answer_question", questionId = question.id, answer = "The SM"), + jwt, + ) + + assertThat(result.ok).isFalse() + assertThat(result.message).contains("already passed") + verify(exactly = 0) { questionAttemptService.submitQuestionAttemptForMe(any(), any(), any()) } + } + @Test fun `a short-text answer is sent as the hire wrote it`() = runTest { val question = question("What is your definition of done?", QuestionStatus.RETRY) From bbac44a3a5d93b5328c36fd2c3822b84b6a9a80b Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 14 Sep 2026 15:28:43 +0200 Subject: [PATCH 09/22] Point the buddy at steps it should close, and at refreshers after a miss The path tool now names steps whose checklist is fully ticked while the step is still open, and what each one holds up. That is the quiet way a hire gets stuck: the work is done, the step's own button was never pressed, and whatever waits on it stays locked. The mentor raises it and offers complete_step; the greeting can open on it too. A question answered wrong now carries a hint to go through the material and, if what was missed is bigger than one explanation, offer one refresher step in that phase -- never the answer. add_path_step's description names this third use. Step links now land on the onboarding page (/onboarding?step=) instead of the step's own page, matching questions and phases. Co-Authored-By: Claude Opus 5 --- .../onboarding/service/BuddyPathActions.kt | 18 +- .../onboarding/service/BuddyPathTools.kt | 157 +++++++++++++++++- .../onboarding/service/BuddyPathToolsTest.kt | 78 ++++++++- 3 files changed, 240 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index 28829a72..8fda7a19 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -577,9 +577,11 @@ class BuddyPathActions( description = "Offer to mark one step of the hire's onboarding path as done. Read " + "get_my_onboarding_path first and pass that step's step_id. This does NOT complete " + "anything by itself: the hire sees a confirm button naming the step, and only they " + - "can click it. Use it when they say they have finished something — never because " + - "the conversation went well, and never to tidy up their path. Only they know " + - "whether the work is done, so ask; do not announce it as done.", + "can click it. Use it when they say they have finished something, and when " + + "get_my_onboarding_path lists a step as READY TO CLOSE (its checklist is done but " + + "the step is still open, so what waits on it stays locked) — never because the " + + "conversation went well, and never to tidy up their path. Only they know whether " + + "the work is done, so ask; do not announce it as done.", parameters = buildJsonObject { put("type", "object") putJsonObject("properties") { @@ -666,10 +668,14 @@ class BuddyPathActions( "onboarding path. Read get_my_onboarding_path for the phase_id. This does NOT add " + "anything by itself; the hire confirms, and afterwards the step is theirs to edit, " + "reorder or delete. It never touches their PM's blueprint — the curriculum is the " + - "PM's, and you are not editing it. Use it for two things and little else: a phase " + + "PM's, and you are not editing it. Use it for three things and little else: a phase " + "that came back empty, where the two of you have worked out something concrete it " + - "should contain; and something real the hire is stuck on that their path does not " + - "mention, so it stops living in a conversation that is gone tomorrow. Give a title " + + "should contain; something real the hire is stuck on that their path does not " + + "mention, so it stops living in a conversation that is gone tomorrow; and a " + + "knowledge question they got wrong, once you have gone through the material, when " + + "what they missed is bigger than one explanation — then one short refresher step in " + + "that question's phase, saying what to revisit and where, and never the answer. " + + "Offer it; do not add one after every wrong answer. Give a title " + "of a few words and a description saying what doing it involves. Do not offer a " + "step for something already on their path, do not add several at once, and do not " + "add one just to have added something.", diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index 086dcd48..ce272e4f 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -7,6 +7,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnbo import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject @@ -87,11 +88,15 @@ class BuddyPathTools( val currentIndex = phases.indexOfFirst { it.isOpen() }.takeIf { it >= 0 } ?: phases.lastIndex + val current = phases[currentIndex] + val checklists = checklistsOf(current) + return buildString { appendLine(standing(phases, currentIndex)) appendLine() - appendCurrentPhase(phases[currentIndex], currentIndex, phases) - appendCurrentTasks(stepTheyAreOn(phases[currentIndex])) + appendCurrentPhase(current, currentIndex, phases) + appendReadyToClose(current, phases, checklists) + appendCurrentTasks(stepTheyAreOn(current), checklists) appendNextItem(phases) appendAhead(phases, currentIndex) appendEmptyPhases(path) @@ -99,6 +104,112 @@ class BuddyPathTools( } } + /** + * The checklist of every step in [phase] the hire could still finish, keyed by step id. + * + * Read once per call and shared, because two sections need it: the checklist of the step they + * are on, and the steps whose checklist is already done. Locked and finished steps are left out + * -- neither can be finished now, so neither checklist is anything to talk about -- and so is a + * locked phase, where nothing can. + */ + private fun checklistsOf(phase: GetOnboardingPhaseForUserResponse): Map> { + if (phase.locked) return emptyMap() + return phase.steps + .filter { it.isFinishable() } + .associate { step -> + step.id to onboardingTaskService.getOnboardingTasksByStepId(step.id).sortedBy { it.position } + } + } + + /** + * The steps whose every checklist line is ticked while the step itself is still open. + * + * The most common way a hire gets stuck without knowing it: they did the work, ticked every line, + * and never pressed the step's own button -- so whatever waits on the step stays locked, and the + * page gives no reason. A checklist that is done is not a finished step (the product keeps the + * two apart on purpose), which is exactly why this is something to *ask* about rather than + * something to assume. A step with no checklist is never on this list: there is nothing that + * says it is done. + */ + private fun readyToClose( + phase: GetOnboardingPhaseForUserResponse, + checklists: Map>, + ): List = + phase.steps + .sortedBy { it.position } + .filter { step -> checklists[step.id]?.let { it.isNotEmpty() && it.all { task -> task.finished } } == true } + + /** + * What finishing [step] would open up, named: the items in its phase that wait on it, and -- + * when it is the last open thing in the phase -- the phases that wait on the phase. + * + * Named because "it unlocks the next thing" is a reason nobody can check, and "it is what #3 + * waits on" is one the hire can see on their page. + */ + private fun unlockedBy( + step: GetOnboardingStepsResponse, + phase: GetOnboardingPhaseForUserResponse, + phases: List, + numbers: Map, + ): List { + val inPhase = phase.steps + .filter { step.id in it.blockerIds && it.id != step.id } + .map { "#${numbers[it.id]} ${quoted(it.title)}" } + + phase.questions + .filter { step.id in it.blockerIds } + .map { "#${numbers[it.id]} ${quoted(it.question)}" } + + // Locked steps count as open here: one waiting on this step is still between it and the end. + val lastOpen = phase.steps.none { + it.id != step.id && it.status != StepStatus.FINISHED && it.status != StepStatus.SKIPPED + } && + phase.questions.all { it.status == QuestionStatus.PASSED } + val laterPhases = if (lastOpen) { + phases.filter { phase.id in it.blockerIds }.map { "the phase ${quoted(it.title)}" } + } else { + emptyList() + } + return inPhase + laterPhases + } + + /** + * Steps they have done the work of but not closed, with what each is holding up. + * + * Addressed to the mentor with an instruction, because this is the one case where it should bring + * a completion up *unprompted*: a hire who does not know why their next step is locked is not + * going to ask about the step that is locking it. + */ + private fun StringBuilder.appendReadyToClose( + phase: GetOnboardingPhaseForUserResponse, + phases: List, + checklists: Map>, + ) { + val ready = readyToClose(phase, checklists) + if (ready.isEmpty()) return + + val numbers = numbering(phase.steps.sortedBy { it.position }, phase.questions.sortedBy { it.position }) + append(NEWLINE) + appendLine( + "READY TO CLOSE -- every line of these checklists is ticked, but the step itself is still " + + "open, so nothing that waits on it has unlocked:", + ) + ready.take(ITEMS_SHOWN).forEach { step -> + val waiting = unlockedBy(step, phase, phases, numbers) + val holding = if (waiting.isEmpty()) "" else " -- it is what ${waiting.joinToString(", ")} waits on" + appendLine( + "- #${numbers[step.id]} ${quoted(step.title)} [step_id: ${step.id}] " + + "[link: $STEP_LINK${step.id}]$holding", + ) + } + appendLine( + "Bring this up yourself: first thing when they ask what is next, where they are or why " + + "something is locked, and otherwise in a sentence at the end of your answer. Say the " + + "checklist looks done, name what it is holding up, and call complete_step for it in " + + "the same reply so the button is there. Ask whether they are finished -- a ticked " + + "checklist is a very good sign, not proof. Once is enough: if they say not yet, leave it.", + ) + } + /** * The step whose checklist is worth putting in front of the mentor: the one they have started, or * else the first one they could start. @@ -108,6 +219,7 @@ class BuddyPathTools( * heading over nothing. */ private fun stepTheyAreOn(phase: GetOnboardingPhaseForUserResponse): GetOnboardingStepsResponse? { + if (phase.locked) return null val ordered = phase.steps.sortedBy { it.position }.filterNot { it.locked } return ordered.firstOrNull { it.status == StepStatus.IN_PROGRESS } ?: ordered.firstOrNull { it.status == StepStatus.WAITING } @@ -136,6 +248,16 @@ class BuddyPathTools( appendLine("Onboarding path:") if (current.isOpen()) { appendLine("They are in phase ${currentIndex + 1} of ${phases.size}: ${quoted(current.title)}.") + // First, when there is one: a step whose checklist is done but that was never closed + // is what a greeting can usefully open on, because it is what is quietly holding + // everything after it. + readyToClose(current, checklistsOf(current)).firstOrNull()?.let { + appendLine( + "Every line of the checklist of ${quoted(it.title)} is ticked, but the step " + + "itself is still open, so what comes after it has not unlocked. It is " + + "worth asking whether they are done with it.", + ) + } nextItem(phases)?.let { appendLine("The next thing waiting for them is ${it.plain}.") } } else { appendLine("They have finished every phase of their path.") @@ -256,7 +378,7 @@ class BuddyPathTools( "Knowledge questions. They count like steps, so a phase whose steps are done and " + "whose questions are unanswered is still the phase they are standing in:", ) - questions.take(ITEMS_SHOWN).forEach { appendQuestion(it, numbers, titles) } + questions.take(ITEMS_SHOWN).forEach { appendQuestion(it, phase, numbers, titles) } if (questions.size > ITEMS_SHOWN) appendLine("- and ${questions.size - ITEMS_SHOWN} more") } } @@ -325,6 +447,7 @@ class BuddyPathTools( */ private fun StringBuilder.appendQuestion( question: GetOnboardingQuestionForUserResponse, + phase: GetOnboardingPhaseForUserResponse, numbers: Map, titles: Map, ) { @@ -343,6 +466,17 @@ class BuddyPathTools( appendLine(" · the options they see: " + options.joinToString("; ") { it.label }) } appendBlockers(question.status == QuestionStatus.LOCKED, question.blockerIds, numbers, titles) + // A wrong answer is the clearest signal on the whole path that a step did not land. Teaching + // the material in the conversation comes first; a refresher step is for when what they + // missed is more than one explanation, so it is still there tomorrow. + if (question.status == QuestionStatus.RETRY) { + appendLine( + " · they got this wrong before, so the material behind it did not land. Go through it " + + "with them first. If what they missed is bigger than one explanation, offer " + + "add_path_step for one short refresher step in this phase " + + "[phase_id: ${phase.id}] that says what to revisit and where -- never the answer.", + ) + } } /** @@ -386,9 +520,12 @@ class BuddyPathTools( * hire's own path a moment ago: the resolution in [findStep] is what proves it is theirs, and * doing it twice would not make it truer. */ - private fun StringBuilder.appendCurrentTasks(step: GetOnboardingStepsResponse?) { + private fun StringBuilder.appendCurrentTasks( + step: GetOnboardingStepsResponse?, + checklists: Map>, + ) { if (step == null) return - val tasks = onboardingTaskService.getOnboardingTasksByStepId(step.id).sortedBy { it.position } + val tasks = checklists[step.id].orEmpty() if (tasks.isEmpty()) return append(NEWLINE) @@ -463,6 +600,10 @@ class BuddyPathTools( ) } + /** Whether a step can still be finished: not locked, and neither finished nor skipped. */ + private fun GetOnboardingStepsResponse.isFinishable(): Boolean = + !locked && (status == StepStatus.WAITING || status == StepStatus.IN_PROGRESS) + /** Whether a phase still has anything open: an unfinished step, or an unpassed question. */ private fun GetOnboardingPhaseForUserResponse.isOpen(): Boolean { val openStep = steps.any { it.status != StepStatus.FINISHED && it.status != StepStatus.SKIPPED } @@ -551,11 +692,15 @@ class BuddyPathTools( * renders an app-relative link as an in-app navigation, so "want to take the check?" arrives * as something clickable rather than as directions. * + * All three land on the onboarding page rather than on a step's own page: the page opens the + * item's phase, scrolls to the card and lights it up, and starting it stays the hire's click. + * A link in a conversation is for finding something, not for doing it. + * * They are a contract with the router, which is why they are named here and asserted in * `BuddyPathToolsTest`: a route rename that forgets this file produces links that 404, and a * mentor has no way to notice. */ - const val STEP_LINK = "/onboarding/" + const val STEP_LINK = "/onboarding?step=" const val QUESTION_LINK = "/onboarding?question=" const val PHASE_LINK = "/onboarding?phase=" diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index 58234d40..8a6617e2 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -210,7 +210,7 @@ class BuddyPathToolsTest { val text = tools.execute(userId) - assertThat(text).contains("link: /onboarding/${step.id}") + assertThat(text).contains("link: /onboarding?step=${step.id}") assertThat(text).contains("link: /onboarding?question=${question.id}") assertThat(text).contains("link: /onboarding?phase=${phase.id}") } @@ -275,6 +275,82 @@ class BuddyPathToolsTest { assertThat(text).contains("finished with lines still open") } + @Test + fun `a step whose checklist is done but that is still open is named, with what it holds up`() { + // Every line ticked, the step's own button never pressed: whatever waits on it stays locked + // and the hire has no idea why. The mentor has to raise it, and has to be able to say why. + val done = step("Clone the repository", StepStatus.IN_PROGRESS) + val waiting = step("Run the tests", StepStatus.WAITING, locked = true, blockers = setOf(done.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(done, waiting))) + every { onboardingTaskService.getOnboardingTasksByStepId(done.id) } returns listOf( + task("Install git", finished = true), + task("Clone it", finished = true), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("READY TO CLOSE") + assertThat(text).contains("#1 “Clone the repository” [step_id: ${done.id}]") + assertThat(text).contains("it is what #2 “Run the tests” waits on") + assertThat(text).contains("call complete_step for it in the same reply") + } + + @Test + fun `a step with open lines, or with no checklist at all, is not ready to close`() { + // No checklist is not a done checklist: there is nothing that says the work happened. + val partly = step("Clone the repository", StepStatus.IN_PROGRESS) + val bare = step("Read the handbook", StepStatus.WAITING) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(partly, bare))) + every { onboardingTaskService.getOnboardingTasksByStepId(partly.id) } returns listOf( + task("Install git", finished = true), + task("Clone it", finished = false), + ) + + assertThat(tools.execute(userId)).doesNotContain("READY TO CLOSE") + } + + @Test + fun `closing the last open step of a phase names the phases waiting on it`() { + val done = step("Clone the repository", StepStatus.IN_PROGRESS) + val setup = phase(0, "Setup", steps = listOf(done)) + val next = phase(1, "First change", locked = true).copy(blockerIds = setOf(setup.id)) + every { onboardingPathService.findPathForUserId(userId) } returns path(setup, next) + every { onboardingTaskService.getOnboardingTasksByStepId(done.id) } returns + listOf(task("Clone it", finished = true)) + + assertThat(tools.execute(userId)).contains("it is what the phase “First change” waits on") + } + + @Test + fun `the greeting opens on a step that is done but never closed`() { + val done = step("Clone the repository", StepStatus.IN_PROGRESS) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(done))) + every { onboardingTaskService.getOnboardingTasksByStepId(done.id) } returns + listOf(task("Clone it", finished = true)) + + val snapshot = tools.snapshotFor(userId) + + assertThat(snapshot).contains("Every line of the checklist of “Clone the repository” is ticked") + // Addressed to a greeting, which holds no tools: no tool name in front of the hire. + assertThat(snapshot).doesNotContain("complete_step") + } + + @Test + fun `a question answered wrong suggests a refresher step, and never the answer`() { + val missed = question("Who runs the retro?", QuestionStatus.RETRY, options = listOf("The SM", "The PO")) + val setup = phase(0, "Meetings", questions = listOf(missed)) + every { onboardingPathService.findPathForUserId(userId) } returns path(setup) + + val text = tools.execute(userId) + + assertThat(text).contains("they got this wrong before") + assertThat(text).contains("add_path_step for one short refresher step in this phase [phase_id: ${setup.id}]") + assertThat(text).contains("never the answer") + } + @Test fun `a locked step's checklist is never the one put in front of the mentor`() { val locked = step("Deploy to staging", StepStatus.WAITING, locked = true) From b6c8285cb603041e0a1a9130fffee50f9542c20f Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 14 Sep 2026 15:52:11 +0200 Subject: [PATCH 10/22] Let the buddy file skip requests, and respect the ones waiting on a PM Adds request_skip: the buddy helps the hire put their reason into words and proposes the same skip request the step page files, with the reason on the confirm payload. The PM accepts or declines it through the existing review flow. A step that already has a pending request is refused with a pointer to the step page; asking again after a decline puts the PM's comment in front of the mentor. A pending skip is now visible and honoured on the path tool: the step is marked SKIP REQUESTED, never named as the next thing or as ready to close, and the PM's review comment is shown. complete_step on such a step says on the button and to the mentor that finishing it withdraws the request, which the completion route does silently. Co-Authored-By: Claude Opus 5 --- .../external/enums/BuddyActionType.kt | 9 + .../external/model/BuddyStreamEvent.kt | 2 + .../model/request/buddy/BuddyActionRequest.kt | 2 + .../onboarding/service/BuddyActionService.kt | 6 +- .../onboarding/service/BuddyPathActions.kt | 154 +++++++++++++++++- .../onboarding/service/BuddyPathTools.kt | 43 ++++- .../onboarding/service/BuddyService.kt | 1 + .../onboarding/service/BuddyPathActionTest.kt | 118 +++++++++++++- .../onboarding/service/BuddyPathToolsTest.kt | 44 +++++ 9 files changed, 367 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt index a16b8e23..89436357 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt @@ -78,6 +78,15 @@ enum class BuddyActionType( * line of progress or do nothing at all. */ COMPLETE_TASK("complete_task", "Tick this off"), + + /** + * Asks the hire's PM to let them skip one step of their path, with the hire's reason. + * + * A *request*, and the PM's decision -- exactly the one the step page files. The mentor helps put + * the reason into words, and the button shows the whole of it, because it is sent to a person in + * the hire's name. + */ + REQUEST_SKIP("request_skip", "Ask your PM to skip this"), ; companion object { diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt index 35994805..2bf43b53 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt @@ -53,4 +53,6 @@ data class BuddyStreamEvent( @SerialName("onboarding_task_id") val onboardingTaskId: String? = null, val answer: String? = null, val description: String? = null, + /** `request_skip` confirm payload: the reason that goes to the PM. */ + val reason: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt index 35652231..92e2d853 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt @@ -57,4 +57,6 @@ data class BuddyActionRequest( val answer: String? = null, /** What a step added by `add_path_step` is about, one or two sentences. */ val description: String? = null, + /** The reason `request_skip` sends to the PM, in the words the hire confirmed. */ + val reason: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index fbfea5c1..87892a11 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -53,7 +53,7 @@ class BuddyActionService( /** * The action tools the AI reasoner is told it may propose, alongside the read-only tools. * - * Per hire rather than globally, for the same reason [BuddyToolExecutor.toolSpecs] is: the four + * Per hire rather than globally, for the same reason [BuddyToolExecutor.toolSpecs] is: the * path actions have a subject that may not exist. A mentor handed `complete_step` for somebody * with no onboarding path will offer to tick a step off a plan they have not got. */ @@ -390,6 +390,7 @@ class BuddyActionService( BuddyActionType.COMPLETE_TASK, BuddyActionType.ANSWER_QUESTION, BuddyActionType.ADD_PATH_STEP, + BuddyActionType.REQUEST_SKIP, -> error("handled above") } } @@ -574,6 +575,7 @@ class BuddyActionService( BuddyActionType.COMPLETE_TASK -> "tick a line off their checklist" BuddyActionType.ANSWER_QUESTION -> "send an answer to a question" BuddyActionType.ADD_PATH_STEP -> "add a step to their path" + BuddyActionType.REQUEST_SKIP -> "ask their PM to skip a step" } /** The result of proposing an action: what to tell the AI, and the proposal to show the hire (if any). */ @@ -609,6 +611,8 @@ class BuddyActionService( val onboardingTaskId: UUID? = null, val answer: String? = null, val description: String? = null, + /** The reason `request_skip` would send to the PM. */ + val reason: String? = null, ) private sealed interface ProjectResolution { diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index 8fda7a19..e0a6c089 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -10,6 +10,7 @@ import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCal import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.question.SubmitQuestionAttemptRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.skip.CreateOnboardingSkipRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.task.UpdateOnboardingTaskRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.buddy.BuddyActionResponse @@ -32,7 +33,7 @@ import java.util.UUID /** * The actions that let a conversation move the hire along their onboarding path. * - * A component of its own rather than four more branches in [BuddyActionService], for the reason + * A component of its own rather than more branches in [BuddyActionService], for the reason * [BuddyBoardTools] is one: they share a subject that the rest of the catalog does not touch, and * they come as a set. [BuddyActionService] still owns the propose/confirm contract — it routes to * this and emits what comes back — so there is exactly one place where an action becomes a button. @@ -59,6 +60,7 @@ class BuddyPathActions( private val onboardingStepService: OnboardingStepService, private val onboardingTaskService: OnboardingTaskService, private val questionAttemptService: QuestionAttemptService, + private val onboardingSkipService: OnboardingSkipService, private val userApi: UserApi, ) { /** @@ -74,7 +76,7 @@ class BuddyPathActions( if (!buddyPathTools.hasPath(userId)) { emptyList() } else { - listOf(COMPLETE_STEP_SPEC, COMPLETE_TASK_SPEC, ANSWER_QUESTION_SPEC, ADD_PATH_STEP_SPEC) + listOf(COMPLETE_STEP_SPEC, COMPLETE_TASK_SPEC, ANSWER_QUESTION_SPEC, ADD_PATH_STEP_SPEC, REQUEST_SKIP_SPEC) } /** Whether [type] is one of this component's actions. */ @@ -82,10 +84,11 @@ class BuddyPathActions( type == BuddyActionType.COMPLETE_STEP || type == BuddyActionType.COMPLETE_TASK || type == BuddyActionType.ANSWER_QUESTION || - type == BuddyActionType.ADD_PATH_STEP + type == BuddyActionType.ADD_PATH_STEP || + type == BuddyActionType.REQUEST_SKIP /** - * Offers one of the four, checked against the hire's own path before the hire sees a button. + * Offers one of them, checked against the hire's own path before the hire sees a button. * * Every precondition is resolved here rather than at confirm time, for the reason the assessment * proposal gives: a step that is already finished, a locked question, an answer that matches no @@ -104,6 +107,7 @@ class BuddyPathActions( BuddyActionType.COMPLETE_STEP -> proposeCompleteStep(call, type, userId) BuddyActionType.COMPLETE_TASK -> proposeCompleteTask(call, type, userId) BuddyActionType.ANSWER_QUESTION -> proposeAnswer(call, type, userId) + BuddyActionType.REQUEST_SKIP -> proposeSkip(call, type, userId) else -> proposeAddPathStep(call, type, userId) } @@ -117,6 +121,7 @@ class BuddyPathActions( BuddyActionType.COMPLETE_STEP -> completeStep(authId, request.stepId) BuddyActionType.COMPLETE_TASK -> completeTask(authId, request.onboardingTaskId) BuddyActionType.ANSWER_QUESTION -> answerQuestion(authId, request.questionId, request.answer) + BuddyActionType.REQUEST_SKIP -> requestSkip(authId, request.stepId, request.reason) else -> addPathStep(authId, request.phaseId, request.title, request.description) } @@ -157,13 +162,28 @@ class BuddyPathActions( } if (refusal != null) return refused(refusal) + // Finishing a step withdraws a skip request still waiting on the PM. Allowed -- somebody who + // did the step anyway should be able to close it -- but never without saying so, on the + // button and to the mentor, because nothing else would tell them the request is gone. + val pendingSkip = step.skip != null && step.skip.accepted == null + val withdraws = if (pendingSkip) { + " They asked their PM to skip this step and nobody has decided yet: finishing it withdraws " + + "that request, so say so plainly before they click." + } else { + "" + } + return BuddyActionService.ProposeOutcome( toolResult = "Proposed to the hire: mark “${step.title}” as done. They see a confirm " + "button and nothing changes unless they click it. Ask whether they have actually " + - "done it — never say that it is done.", + "done it — never say that it is done.$withdraws", proposal = BuddyActionService.BuddyActionProposal( action = type.toolName, - label = "Mark “${step.title}” as done", + label = if (pendingSkip) { + "Mark “${step.title}” as done (withdraws your skip request)" + } else { + "Mark “${step.title}” as done" + }, question = null, stepId = step.id, ), @@ -241,6 +261,93 @@ class BuddyPathActions( ) } + /** + * Offers to send the hire's skip request for one step to their PM. + * + * The same request the step page files, reached from a conversation: the mentor's part is + * helping the hire say *why*, because a reason the PM can act on is the difference between a + * request that is decided and one that sits. The reason goes out in the hire's name, so the + * proposal carries all of it and the button shows it before anything is sent. + * + * Checked here rather than left to the route, so the mentor hears "already asked" or "already + * done" while it can still say something useful about it. + */ + private fun proposeSkip( + call: BuddyToolCallDto, + type: BuddyActionType, + userId: UUID, + ): BuddyActionService.ProposeOutcome { + val stepId = call.uuidArg("step_id") + ?: return refused( + "No step_id was provided. Read get_my_onboarding_path and pass the step_id of the " + + "step they want to skip.", + ) + val step = buddyPathTools.findStep(userId, stepId) + ?: return refused( + "No step of this hire's own path has that id. Read get_my_onboarding_path again.", + ) + val reason = call.stringArg("reason").trim() + val previous = step.skip + + val refusal = when { + step.status == StepStatus.FINISHED -> + "“${step.title}” is already done, so there is nothing to skip." + step.status == StepStatus.SKIPPED -> + "“${step.title}” is already skipped — their PM accepted it." + previous != null && previous.accepted == null -> + "They already asked to skip “${step.title}” and their PM has not decided yet. A second " + + "request cannot be sent; they can change the reason on the step's own page " + + "(${BuddyPathTools.STEP_PAGE_LINK}${step.id})." + reason.isBlank() -> + "No reason was provided. Their PM decides on the reason, so ask why they want to skip " + + "it — already know it, not relevant to their role, covered elsewhere — and put " + + "that into a sentence or two before offering this again." + else -> null + } + if (refusal != null) return refused(refusal) + + // A declined request can be asked again, and the PM said why the first time. The new reason + // should answer that, or it will be declined for the same thing. + val declinedBefore = previous + ?.takeIf { it.accepted == false } + ?.let { declined -> + " Their PM declined an earlier request" + + (declined.reviewComment?.takeIf { it.isNotBlank() }?.let { " with: “$it”" } ?: "") + + " — make sure this reason addresses that." + }.orEmpty() + + return BuddyActionService.ProposeOutcome( + toolResult = "Proposed to the hire: ask their PM to skip “${step.title}”, with the reason " + + "“$reason”. They see a confirm button showing that reason, and nothing is sent unless " + + "they click it. Their PM decides; until then the step stays on their path, and if it " + + "is accepted it counts as done and unlocks what waits on it. Do not promise it will " + + "be accepted.$declinedBefore", + proposal = BuddyActionService.BuddyActionProposal( + action = type.toolName, + label = "Ask your PM to skip “${step.title}”", + question = null, + stepId = step.id, + reason = reason, + ), + ) + } + + /** Files the confirmed skip request through the hire's own route, which owns every rule about it. */ + private fun requestSkip(authId: String, stepId: UUID?, reason: String?): BuddyActionResponse { + if (stepId == null || reason.isNullOrBlank()) { + return BuddyActionResponse(ok = false, message = "No skip request was proposed to send.") + } + val step = buddyPathTools.findStep(resolveUserId(authId), stepId) + ?: return BuddyActionResponse(ok = false, message = "That step isn't on your path.") + + onboardingSkipService.createOnboardingSkipForMe(authId, stepId, CreateOnboardingSkipRequest(reason = reason)) + return BuddyActionResponse( + ok = true, + message = "Sent — your PM will decide on skipping “${step.title}”. Until then it stays on " + + "your path; you can change or withdraw the reason on the step's page.", + ) + } + /** * Offers to add a step the conversation produced to a phase of the hire's own path. * @@ -662,6 +769,41 @@ class BuddyPathActions( }, ) + val REQUEST_SKIP_SPEC = BuddyToolSpecDto( + name = BuddyActionType.REQUEST_SKIP.toolName, + description = "Offer to send the hire's request to skip one step of their onboarding path " + + "to their PM. Read get_my_onboarding_path for the step_id. Use it when THEY want to " + + "skip a step — never suggest skipping to get through the path faster, and never for a " + + "step just because it looks hard. Their PM decides, so the reason is what matters: " + + "ask why before you offer this, help them put it into one or two clear sentences " + + "(what they already know, why it does not apply to their role, where it is covered " + + "already), and pass that. It goes out in their name, so it must say what they said, " + + "not what you think. This does NOT send anything by itself; they see a confirm button " + + "with the reason. Do not promise it will be accepted. A step that already has a " + + "request waiting cannot get a second one.", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("step_id") { + put("type", "string") + put("description", "The step_id from get_my_onboarding_path.") + } + putJsonObject("reason") { + put("type", "string") + put( + "description", + "Why they want to skip it, in one or two sentences their PM can decide on, " + + "written as the hire.", + ) + } + } + putJsonArray("required") { + add("step_id") + add("reason") + } + }, + ) + val ADD_PATH_STEP_SPEC = BuddyToolSpecDto( name = BuddyActionType.ADD_PATH_STEP.toolName, description = "Offer to add one step to a phase of the hire's OWN copy of their " + diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index ce272e4f..c9069b40 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -137,6 +137,8 @@ class BuddyPathTools( ): List = phase.steps .sortedBy { it.position } + // Not a step they asked to skip: pushing them to finish it would withdraw the request. + .filterNot { it.hasPendingSkip() } .filter { step -> checklists[step.id]?.let { it.isNotEmpty() && it.all { task -> task.finished } } == true } /** @@ -413,6 +415,7 @@ class BuddyPathTools( val state = when { step.status == StepStatus.FINISHED -> "done" step.status == StepStatus.SKIPPED -> "skipped" + step.hasPendingSkip() -> "SKIP REQUESTED, waiting on their PM" step.locked -> "LOCKED, cannot be started yet" step.status == StepStatus.IN_PROGRESS -> "started" else -> "open" @@ -426,8 +429,17 @@ class BuddyPathTools( appendLine(" · should leave them able to: $it") } appendBlockers(step.locked, step.blockerIds, numbers, titles) - // A skip already asked for is the one thing about a step whose state is nowhere else in this - // text, and a mentor that cannot see it will offer to request a second one. + appendSkip(step) + } + + /** + * Where a skip request for [step] stands, and what that means for the mentor. + * + * A skip already asked for is the one thing about a step whose state is nowhere else in this + * text, and a mentor that cannot see it will offer to request a second one -- or push the hire + * to finish a step whose skip is waiting on their PM, which withdraws the request. + */ + private fun StringBuilder.appendSkip(step: GetOnboardingStepsResponse) { step.skip?.let { skip -> val verdict = when (skip.accepted) { true -> "their PM accepted it" @@ -435,6 +447,19 @@ class BuddyPathTools( null -> "nobody has decided yet" } appendLine(" · they asked to skip this ($verdict): ${quoted(skip.reason)}") + // The PM's own words are the most useful thing about a decision, and a declined request + // is exactly when the hire will want to talk about why. + skip.reviewComment?.takeIf { it.isNotBlank() }?.let { + appendLine(" · their PM's comment on it: ${quoted(it)}") + } + if (skip.accepted == null) { + appendLine( + " · while it is pending, do not push them to do this step, and do not offer " + + "complete_step for it unless they say they did it anyway: finishing the step " + + "withdraws the request. They can change or withdraw the reason on the step's " + + "own page [page: $STEP_PAGE_LINK${step.id}].", + ) + } } } @@ -600,6 +625,9 @@ class BuddyPathTools( ) } + /** Whether the hire asked to skip this step and their PM has not decided yet. */ + private fun GetOnboardingStepsResponse.hasPendingSkip(): Boolean = skip != null && skip.accepted == null + /** Whether a step can still be finished: not locked, and neither finished nor skipped. */ private fun GetOnboardingStepsResponse.isFinishable(): Boolean = !locked && (status == StepStatus.WAITING || status == StepStatus.IN_PROGRESS) @@ -628,7 +656,11 @@ class BuddyPathTools( val step = phase.steps .sortedBy { it.position } .firstOrNull { - !it.locked && it.status != StepStatus.FINISHED && it.status != StepStatus.SKIPPED + !it.locked && + it.status != StepStatus.FINISHED && + it.status != StepStatus.SKIPPED && + // Asked to skip and waiting on the PM: not what to tell them to do next. + !it.hasPendingSkip() } val question = phase.questions .sortedBy { it.position } @@ -701,6 +733,9 @@ class BuddyPathTools( * mentor has no way to notice. */ const val STEP_LINK = "/onboarding?step=" + + /** A step's own page, where its checklist, its skip request and its reason live. */ + const val STEP_PAGE_LINK = "/onboarding/" const val QUESTION_LINK = "/onboarding?question=" const val PHASE_LINK = "/onboarding?phase=" @@ -747,6 +782,8 @@ class BuddyPathTools( "says what it waits on. Never tell the hire they can go ahead with a locked item, " + "even if they ask directly: say what has to be finished first and offer that " + "instead.\n" + + "A step marked SKIP REQUESTED is waiting on their PM: do not push them to do it. " + + "When they want to skip a step, that is request_skip -- their PM decides.\n" + "It does not tell you which answer to a question is correct -- that is deliberate, " + "and you must not guess one aloud: explain the material and let the hire answer. " + "Reading it changes nothing. Takes no arguments -- it always reads the caller.", diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt index 2a647a69..a6a1c644 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt @@ -415,6 +415,7 @@ class BuddyService( onboardingTaskId = proposal.onboardingTaskId?.toString(), answer = proposal.answer, description = proposal.description, + reason = proposal.reason, ), ) } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt index 5c1d1dca..5932e98c 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -2,18 +2,22 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.SkipStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.question.SubmitQuestionAttemptRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.skip.CreateOnboardingSkipRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.task.UpdateOnboardingTaskRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.SubmitQuestionAttemptResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.CreateOnboardingSkipResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.GetOnboardingStepSkipResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.UpdateOnboardingStepResponse @@ -35,7 +39,7 @@ import java.util.Optional import java.util.UUID /** - * The four actions that let a conversation move somebody along their onboarding path. + * The actions that let a conversation move somebody along their onboarding path. * * The through-line, and the reason each of these is a *proposal*: **the mentor may say what it * thinks, and the hire is the one who changes their own onboarding.** Three rules follow, and every @@ -51,9 +55,10 @@ class BuddyPathActionTest { private val onboardingStepService: OnboardingStepService = mockk() private val onboardingTaskService: OnboardingTaskService = mockk() private val questionAttemptService: QuestionAttemptService = mockk() + private val onboardingSkipService: OnboardingSkipService = mockk() private val userApi: UserApi = mockk() - // The real path component behind a real action service: these cases are about the four actions + // The real path component behind a real action service: these cases are about the path actions // *and* about BuddyActionService routing them around the project gate, and mocking the component // would test the routing against nothing. private val pathActions = BuddyPathActions( @@ -61,6 +66,7 @@ class BuddyPathActionTest { onboardingStepService = onboardingStepService, onboardingTaskService = onboardingTaskService, questionAttemptService = questionAttemptService, + onboardingSkipService = onboardingSkipService, userApi = userApi, ) @@ -410,6 +416,105 @@ class BuddyPathActionTest { assertThat(submitted.captured.selectedOptionIds).isEmpty() } + // -- request_skip ----------------------------------------------------------------------------- + + @Test + fun `a skip request without a reason sends the mentor back to ask why`() { + // The PM decides on the reason; a request with none is one that sits. + val step = step("Set up the VPN", StepStatus.WAITING) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("request_skip", "step_id" to step.id.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("ask why they want to skip it") + } + + @Test + fun `a step already waiting on a skip decision cannot get a second request`() { + val step = step("Set up the VPN", StepStatus.WAITING).copy(skip = skip(accepted = null)) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose( + call("request_skip", "step_id" to step.id.toString(), "reason" to "I already have access."), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("has not decided yet") + // Where they can change it instead, since a second request is not possible. + assertThat(outcome.toolResult).contains("/onboarding/${step.id}") + } + + @Test + fun `the skip proposal carries the reason, and proposing sends nothing`() { + val step = step("Set up the VPN", StepStatus.WAITING) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose( + call("request_skip", "step_id" to step.id.toString(), "reason" to "I already have VPN access."), + userId, + ) + + assertThat(outcome.proposal?.label).isEqualTo("Ask your PM to skip “Set up the VPN”") + assertThat(outcome.proposal?.reason).isEqualTo("I already have VPN access.") + assertThat(outcome.toolResult).contains("Do not promise it will be accepted") + verify(exactly = 0) { onboardingSkipService.createOnboardingSkipForMe(any(), any(), any()) } + } + + @Test + fun `asking again after a decline puts the PM's comment in front of the mentor`() { + val step = step("Set up the VPN", StepStatus.WAITING) + .copy(skip = skip(accepted = false, reviewComment = "Everyone needs the company VPN.")) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose( + call("request_skip", "step_id" to step.id.toString(), "reason" to "I work on-site only."), + userId, + ) + + assertThat(outcome.proposal).isNotNull() + assertThat(outcome.toolResult).contains("“Everyone needs the company VPN.”") + assertThat(outcome.toolResult).contains("addresses that") + } + + @Test + fun `a confirmed skip request goes through the hire's own skip route`() = runTest { + val step = step("Set up the VPN", StepStatus.WAITING) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findStep(userId, step.id) } returns step + val sent = slot() + every { onboardingSkipService.createOnboardingSkipForMe(authId, step.id, capture(sent)) } returns + CreateOnboardingSkipResponse( + id = UUID.randomUUID(), + stepId = step.id, + status = SkipStatus.PENDING, + reason = "I already have VPN access.", + createdAt = Instant.EPOCH, + ) + + val result = service.perform( + BuddyActionRequest(action = "request_skip", stepId = step.id, reason = "I already have VPN access."), + jwt, + ) + + assertThat(result.ok).isTrue() + assertThat(sent.captured.reason).isEqualTo("I already have VPN access.") + assertThat(result.message).contains("your PM will decide") + } + + @Test + fun `completing a step with a pending skip says on the button that it withdraws the request`() { + // The completion route drops a pending skip. Allowed, but never silently. + val step = step("Set up the VPN", StepStatus.IN_PROGRESS).copy(skip = skip(accepted = null)) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_step", "step_id" to step.id.toString()), userId) + + assertThat(outcome.proposal?.label).contains("withdraws your skip request") + assertThat(outcome.toolResult).contains("finishing it withdraws that request") + } + // -- add_path_step ---------------------------------------------------------------------------- @Test @@ -557,6 +662,15 @@ class BuddyPathActionTest { status = status, ) + private fun skip(accepted: Boolean?, reviewComment: String? = null) = GetOnboardingStepSkipResponse( + id = UUID.randomUUID(), + stepId = UUID.randomUUID(), + reason = "I already know this.", + accepted = accepted, + reviewComment = reviewComment, + reviewedAt = null, + ) + private fun task(title: String, finished: Boolean, stepId: UUID) = GetOnboardingTaskResponse( id = UUID.randomUUID(), stepId = stepId, diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index 8a6617e2..2e689722 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -10,6 +10,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.path.Onboard import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.GetOnboardingStepSkipResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse import io.mockk.every @@ -351,6 +352,40 @@ class BuddyPathToolsTest { assertThat(text).contains("never the answer") } + @Test + fun `a step waiting on a skip decision is marked, and is never the next thing`() { + // Pushing a hire to do a step they asked to skip, or to finish it -- which withdraws the + // request -- is the mentor overruling a question that is their PM's to answer. + val asked = step("Set up the VPN", StepStatus.WAITING).copy(skip = skip(accepted = null)) + val after = step("Read the handbook", StepStatus.WAITING) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(asked, after))) + every { onboardingTaskService.getOnboardingTasksByStepId(asked.id) } returns + listOf(task("Install the client", finished = true)) + + val text = tools.execute(userId) + + assertThat(text).contains("[SKIP REQUESTED, waiting on their PM] “Set up the VPN”") + assertThat(text).contains("finishing the step withdraws the request") + assertThat(text).contains("[page: /onboarding/${asked.id}]") + assertThat(text).contains("The next thing waiting for them: the step “Read the handbook”") + // Its checklist is done, but closing it would withdraw the request: not ready to close. + assertThat(text).doesNotContain("READY TO CLOSE") + } + + @Test + fun `a declined skip carries the PM's comment`() { + val declined = step("Set up the VPN", StepStatus.WAITING) + .copy(skip = skip(accepted = false, reviewComment = "Everyone needs the company VPN.")) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(declined))) + + val text = tools.execute(userId) + + assertThat(text).contains("their PM declined it") + assertThat(text).contains("their PM's comment on it: “Everyone needs the company VPN.”") + } + @Test fun `a locked step's checklist is never the one put in front of the mentor`() { val locked = step("Deploy to staging", StepStatus.WAITING, locked = true) @@ -494,6 +529,15 @@ class BuddyPathToolsTest { blockerIds = blockers, ) + private fun skip(accepted: Boolean?, reviewComment: String? = null) = GetOnboardingStepSkipResponse( + id = UUID.randomUUID(), + stepId = UUID.randomUUID(), + reason = "I already know this.", + accepted = accepted, + reviewComment = reviewComment, + reviewedAt = null, + ) + private fun task(title: String, finished: Boolean) = GetOnboardingTaskResponse( id = UUID.randomUUID(), stepId = UUID.randomUUID(), From e6632bcd12fbadc56a29020f9e351bb1deac53c7 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 14 Sep 2026 16:12:53 +0200 Subject: [PATCH 11/22] Show the buddy each phase as a graph, and let it place steps inside it The path read now gives every item of the current phase what it comes after and what it opens, lists what is open right now, and says outright that a phase is a dependency graph, not a sequence. Told only about locks, the mentor read the page's numbering as an order and could not place a new step anywhere but the end. add_path_step takes waits_on and unlocks. OnboardingStepPlacementService creates the step and connects it in one transaction: it waits on waits_on, every item in unlocks waits on it instead of on those, and it is drawn between its neighbours. Loops, items from other phases and placements in front of finished items are refused, before the button and again at confirm. A missed question's refresher line names the exact placement in front of that question. Also: the next item follows the page's rule (a step before a question) and puts a ready-to-close step first; that section now gives the answer an order -- where they are, what closing it opens, then a light question with the button -- instead of leading with the button. Options are never to be narrowed down. BuddyPathActionTest is split for size. Co-Authored-By: Claude Opus 5 --- .../external/model/BuddyStreamEvent.kt | 3 + .../model/request/buddy/BuddyActionRequest.kt | 6 + .../onboarding/service/BuddyActionService.kt | 3 + .../onboarding/service/BuddyPathActions.kt | 111 ++++- .../onboarding/service/BuddyPathTools.kt | 165 +++++-- .../onboarding/service/BuddyService.kt | 2 + .../service/OnboardingStepPlacementService.kt | 135 ++++++ .../onboarding/service/PathStepPlacement.kt | 93 ++++ .../onboarding/service/BuddyPathActionTest.kt | 238 +--------- .../service/BuddyPathStepActionTest.kt | 448 ++++++++++++++++++ .../onboarding/service/BuddyPathToolsTest.kt | 61 ++- .../OnboardingStepPlacementServiceTest.kt | 165 +++++++ 12 files changed, 1127 insertions(+), 303 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementService.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementServiceTest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt index 2bf43b53..467c078e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/model/BuddyStreamEvent.kt @@ -55,4 +55,7 @@ data class BuddyStreamEvent( val description: String? = null, /** `request_skip` confirm payload: the reason that goes to the PM. */ val reason: String? = null, + /** `add_path_step` confirm payload: where the step goes in its phase's graph. */ + @SerialName("waits_on_ids") val waitsOnIds: List? = null, + @SerialName("unlocks_ids") val unlocksIds: List? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt index 92e2d853..d5f4ce02 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt @@ -59,4 +59,10 @@ data class BuddyActionRequest( val description: String? = null, /** The reason `request_skip` sends to the PM, in the words the hire confirmed. */ val reason: String? = null, + /** + * Where `add_path_step` puts the new step in its phase's graph: the items it waits on, and the + * items that will wait on it instead. Re-checked against the caller's own path at confirm time. + */ + val waitsOnIds: List = emptyList(), + val unlocksIds: List = emptyList(), ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 87892a11..abf6fb6e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -613,6 +613,9 @@ class BuddyActionService( val description: String? = null, /** The reason `request_skip` would send to the PM. */ val reason: String? = null, + /** Where `add_path_step` would put the step in its phase's graph. */ + val waitsOnIds: List = emptyList(), + val unlocksIds: List = emptyList(), ) private sealed interface ProjectResolution { diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index e0a6c089..9f488ef7 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -18,6 +18,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.question.Get import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse import com.sprintstart.sprintstartbackend.user.external.UserApi +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject @@ -58,6 +59,7 @@ import java.util.UUID class BuddyPathActions( private val buddyPathTools: BuddyPathTools, private val onboardingStepService: OnboardingStepService, + private val onboardingStepPlacementService: OnboardingStepPlacementService, private val onboardingTaskService: OnboardingTaskService, private val questionAttemptService: QuestionAttemptService, private val onboardingSkipService: OnboardingSkipService, @@ -122,7 +124,7 @@ class BuddyPathActions( BuddyActionType.COMPLETE_TASK -> completeTask(authId, request.onboardingTaskId) BuddyActionType.ANSWER_QUESTION -> answerQuestion(authId, request.questionId, request.answer) BuddyActionType.REQUEST_SKIP -> requestSkip(authId, request.stepId, request.reason) - else -> addPathStep(authId, request.phaseId, request.title, request.description) + else -> addPathStep(authId, request) } /** @@ -375,6 +377,7 @@ class BuddyPathActions( ) val title = call.stringArg("title").trim() val description = call.stringArg("description").trim() + val placement = PathStepPlacement(phase, call.uuidListArg("waits_on"), call.uuidListArg("unlocks")) val refusal = when { title.isBlank() -> "No title was provided. Say what the step is, in a few words." @@ -384,23 +387,41 @@ class BuddyPathActions( phase.steps.any { it.title.trim().equals(title, ignoreCase = true) } -> "“$title” is already a step of “${phase.title}”, so nothing needs adding. Point them " + "at the one that is there." - else -> null + else -> placement.problem() } if (refusal != null) return refused(refusal) + val where = placement.describe() + val connection = if (placement.isConnected) { + " It goes $where in the phase's graph: it opens once what it waits on is done, and what " + + "it unlocks now waits on it." + } else { + " It is not connected to anything, so it is open straight away and is never what comes " + + "next — if it belongs somewhere in their path, pass waits_on and unlocks." + } + val relocks = placement + .relocksStarted() + .takeIf { it.isNotEmpty() } + ?.let { + " ${it.joinToString(", ")} is already started and will lock again until this step is " + + "done — say so before they click." + }.orEmpty() + return BuddyActionService.ProposeOutcome( toolResult = "Proposed to the hire: add the step “$title” to the phase " + "“${phase.title}” of their own path. They see a confirm button; nothing is added " + "unless they click it. This changes their copy only — their PM's blueprint is " + "untouched — and they can edit or remove it afterwards. Say what the step is for " + - "before you offer it.", + "and where it goes before you offer it.$connection$relocks", proposal = BuddyActionService.BuddyActionProposal( action = type.toolName, - label = "Add “$title” to your path", + label = if (where.isEmpty()) "Add “$title” to your path" else "Add “$title” $where", question = null, phaseId = phase.id, title = title, description = description, + waitsOnIds = placement.waitsOn.toList(), + unlocksIds = placement.unlocks.toList(), ), ) } @@ -575,27 +596,34 @@ class BuddyPathActions( ) } - private fun addPathStep( - authId: String, - phaseId: UUID?, - title: String?, - description: String?, - ): BuddyActionResponse { + private fun addPathStep(authId: String, request: BuddyActionRequest): BuddyActionResponse { + val phaseId = request.phaseId + val title = request.title if (phaseId == null || title.isNullOrBlank()) { return BuddyActionResponse(ok = false, message = "No step was proposed to add.") } val phase = buddyPathTools.findPhase(resolveUserId(authId), phaseId) ?: return BuddyActionResponse(ok = false, message = "That phase isn't on your path.") - val created = onboardingStepService.createOnboardingStepForMe( - authId, - phaseId, - CreateOnboardingStepRequest( - // At the end of the phase. Where a step the conversation produced belongs in - // somebody else's sequence is not something this can know, and the hire can drag it. - position = phase.steps.size, + // Checked again: the path can change between the button and the click, and a placement + // that was sound then can be a loop or point at something finished now. + val placement = PathStepPlacement(phase, request.waitsOnIds.toSet(), request.unlocksIds.toSet()) + if (placement.problem() != null) { + return BuddyActionResponse( + ok = false, + message = "Your path changed since this was suggested, so the step wasn't added — ask me again.", + ) + } + + val created = onboardingStepPlacementService.createConnectedStepForMe( + authId = authId, + phaseId = phaseId, + request = CreateOnboardingStepRequest( + // Next to what it waits on (or in front of what it unlocks), so the list reads in the + // order the graph opens it. The end, when it is connected to nothing. + position = placement.position(), title = title, - description = description.orEmpty(), + description = request.description.orEmpty(), type = StepType.TASK, estimatedMinutes = ADDED_STEP_MINUTES, // Left empty on purpose: an expected outcome is a promise about what doing the step @@ -606,11 +634,13 @@ class BuddyPathActions( // labelled "Custom step by PM" -- a thing their team requires -- when it was something // they agreed to in a chat. origin = StepOrigin.BUDDY, + waitsOn = placement.waitsOn, + unlocks = placement.unlocks, ) return BuddyActionResponse( ok = true, message = "Added “${created.title}” to “${phase.title}”. It is on your path now, and it " + - "is yours — edit it, reorder it or delete it there like any other step.", + "is yours — edit it or delete it there like any other step.", ) } @@ -662,6 +692,20 @@ class BuddyPathActions( private fun BuddyToolCallDto.stringArg(name: String): String = (arguments[name] as? JsonPrimitive)?.contentOrNull.orEmpty() + /** + * Reads a list of UUIDs the model passed, tolerating a single string where a list was asked for. + * Anything that is not a UUID is dropped here and caught by the placement check as "not in this + * phase" only if it parsed -- an unparseable id is simply not a placement. + */ + private fun BuddyToolCallDto.uuidListArg(name: String): Set { + val raw = when (val value = arguments[name]) { + is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + is JsonPrimitive -> listOfNotNull(value.contentOrNull) + else -> emptyList() + } + return raw.mapNotNull { runCatching { UUID.fromString(it.trim()) }.getOrNull() }.toSet() + } + /** Reads a UUID argument the model passed to a tool, or null when it is missing/unparseable. */ private fun BuddyToolCallDto.uuidArg(name: String): UUID? = runCatching { UUID.fromString(stringArg(name)) }.getOrNull() @@ -820,7 +864,16 @@ class BuddyPathActions( "Offer it; do not add one after every wrong answer. Give a title " + "of a few words and a description saying what doing it involves. Do not offer a " + "step for something already on their path, do not add several at once, and do not " + - "add one just to have added something.", + "add one just to have added something.\n" + + "WHERE IT GOES. A phase is a dependency graph, not a list: an item opens once " + + "everything it waits on is done. A step added with no connections is open at once, " + + "floats unconnected in their graph view and is never what comes next. So place it: " + + "waits_on = the ids it should open after, unlocks = the ids that should wait on it. " + + "To put it in as the NEXT thing, waits_on is the item they are on or just finished, " + + "and unlocks is what currently waits on that item (the path read lists it under " + + "\"opens\"). A refresher for a question they missed goes before that question: " + + "unlocks = [that question_id]. Leave both empty only for a step that genuinely " + + "depends on nothing and holds nothing up.", parameters = buildJsonObject { put("type", "object") putJsonObject("properties") { @@ -839,6 +892,24 @@ class BuddyPathActions( "What doing it involves, in one or two sentences the hire can act on.", ) } + putJsonObject("waits_on") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put( + "description", + "step_id/question_id values in the same phase that must be done before " + + "this step opens.", + ) + } + putJsonObject("unlocks") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put( + "description", + "step_id/question_id values in the same phase that should wait on this " + + "step. They stop waiting directly on anything in waits_on.", + ) + } } putJsonArray("required") { add("phase_id") diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index c9069b40..1474e992 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -97,7 +97,7 @@ class BuddyPathTools( appendCurrentPhase(current, currentIndex, phases) appendReadyToClose(current, phases, checklists) appendCurrentTasks(stepTheyAreOn(current), checklists) - appendNextItem(phases) + appendNextItem(phases, readyToClose(current, checklists).firstOrNull()) appendAhead(phases, currentIndex) appendEmptyPhases(path) append(NEWLINE + CLOSING) @@ -204,11 +204,13 @@ class BuddyPathTools( ) } appendLine( - "Bring this up yourself: first thing when they ask what is next, where they are or why " + - "something is locked, and otherwise in a sentence at the end of your answer. Say the " + - "checklist looks done, name what it is holding up, and call complete_step for it in " + - "the same reply so the button is there. Ask whether they are finished -- a ticked " + - "checklist is a very good sign, not proof. Once is enough: if they say not yet, leave it.", + "This step is where they actually are, and it comes before anything else that is open. " + + "When they ask what is next, where they are or why something is locked, answer in this " + + "order: (1) they are on this step, and its checklist is all ticked; (2) what finishing " + + "it opens, by number; (3) ask whether they are done with it -- and call complete_step " + + "for it in that same reply so the button is there. Keep it light: do not lead with the " + + "button, and do not lecture them about being sure. Otherwise mention it in a sentence at " + + "the end of your answer. Once is enough: if they say not yet, leave it.", ) } @@ -368,10 +370,13 @@ class BuddyPathTools( val numbers = numbering(steps, questions) val titles = titlesIn(phase) + val graph = PhaseGraph(numbers, titles, opensIn(phase)) + + appendGraphIntro(phase, steps, questions, numbers) if (steps.isNotEmpty()) { - appendLine("Steps, in the order the path puts them:") - steps.take(ITEMS_SHOWN).forEach { appendStep(it, numbers, titles) } + appendLine("Steps, numbered as their page shows them:") + steps.take(ITEMS_SHOWN).forEach { appendStep(it, graph) } if (steps.size > ITEMS_SHOWN) appendLine("- and ${steps.size - ITEMS_SHOWN} more") } @@ -380,7 +385,7 @@ class BuddyPathTools( "Knowledge questions. They count like steps, so a phase whose steps are done and " + "whose questions are unanswered is still the phase they are standing in:", ) - questions.take(ITEMS_SHOWN).forEach { appendQuestion(it, phase, numbers, titles) } + questions.take(ITEMS_SHOWN).forEach { appendQuestion(it, phase, graph) } if (questions.size > ITEMS_SHOWN) appendLine("- and ${questions.size - ITEMS_SHOWN} more") } } @@ -402,6 +407,45 @@ class BuddyPathTools( .withIndex() .associate { (index, id) -> id to index + 1 } + /** How to read the phase's items as a graph, and which of them are open right now. */ + private fun StringBuilder.appendGraphIntro( + phase: GetOnboardingPhaseForUserResponse, + steps: List, + questions: List, + numbers: Map, + ) { + appendLine( + "The items of a phase form a dependency graph, not a list: an item opens once everything " + + "it comes after is done, several can be open at the same time, and finishing one can " + + "open several at once. Each item below says what it comes after and what it opens. " + + "Talk about it that way -- never \"after #6 comes #7\" unless #7 really comes after #6.", + ) + val openNow = steps.filter { it.isFinishable() && !it.hasPendingSkip() }.map { it.id } + + questions.filter { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY }.map { it.id } + if (!phase.locked && openNow.isNotEmpty()) { + appendLine("Open right now: " + openNow.joinToString(", ") { "#${numbers[it]}" }) + } + } + + /** + * For every item of [phase], the items that come directly after it -- the edges the graph view + * draws, read the other way round, because "finishing this opens #3 and #4" is the sentence a + * hire can act on and the data only stores "#3 comes after this". + */ + private fun opensIn(phase: GetOnboardingPhaseForUserResponse): Map> { + val edges = phase.steps.map { it.id to it.blockerIds } + phase.questions.map { it.id to it.blockerIds } + return edges + .flatMap { (item, blockers) -> blockers.map { it to item } } + .groupBy({ it.first }, { it.second }) + } + + /** What every line of the current phase needs to name its neighbours. */ + private data class PhaseGraph( + val numbers: Map, + val titles: Map, + val opens: Map>, + ) + /** Every item of a phase by id, so a blocker can be named rather than counted. */ private fun titlesIn(phase: GetOnboardingPhaseForUserResponse): Map = phase.steps.associate { it.id to it.title } + phase.questions.associate { it.id to it.question } @@ -409,9 +453,9 @@ class BuddyPathTools( /** One step: what it is, where it stands, and the ids and link an action or a reply needs. */ private fun StringBuilder.appendStep( step: GetOnboardingStepsResponse, - numbers: Map, - titles: Map, + graph: PhaseGraph, ) { + val numbers = graph.numbers val state = when { step.status == StepStatus.FINISHED -> "done" step.status == StepStatus.SKIPPED -> "skipped" @@ -428,7 +472,7 @@ class BuddyPathTools( step.expectedOutcomes.take(OUTCOMES_SHOWN).forEach { appendLine(" · should leave them able to: $it") } - appendBlockers(step.locked, step.blockerIds, numbers, titles) + appendEdges(step.id, step.locked, step.blockerIds, graph) appendSkip(step) } @@ -473,9 +517,9 @@ class BuddyPathTools( private fun StringBuilder.appendQuestion( question: GetOnboardingQuestionForUserResponse, phase: GetOnboardingPhaseForUserResponse, - numbers: Map, - titles: Map, + graph: PhaseGraph, ) { + val numbers = graph.numbers val state = when (question.status) { QuestionStatus.PASSED -> "passed" QuestionStatus.RETRY -> "answered wrong before, still open" @@ -489,49 +533,62 @@ class BuddyPathTools( val options = question.options.sortedBy { it.position } if (options.isNotEmpty()) { appendLine(" · the options they see: " + options.joinToString("; ") { it.label }) + // Hinting is answering. Testing had the mentor say one option "matches the title of #1 + // word for word" -- no answer stated, and the question given away all the same. + appendLine( + " · never narrow these down for them: not by pointing at an option that matches a " + + "title or wording elsewhere, not by ruling any out, and not by saying how close a " + + "wrong answer was -- you do not know.", + ) } - appendBlockers(question.status == QuestionStatus.LOCKED, question.blockerIds, numbers, titles) + appendEdges(question.id, question.status == QuestionStatus.LOCKED, question.blockerIds, graph) // A wrong answer is the clearest signal on the whole path that a step did not land. Teaching // the material in the conversation comes first; a refresher step is for when what they // missed is more than one explanation, so it is still there tomorrow. if (question.status == QuestionStatus.RETRY) { + // Placed before the question, so the refresher is what opens it: waits_on takes over + // whatever the question waits on now, and the question waits on the refresher instead. + val waitsOn = question.blockerIds.joinToString(", ") appendLine( " · they got this wrong before, so the material behind it did not land. Go through it " + "with them first. If what they missed is bigger than one explanation, offer " + - "add_path_step for one short refresher step in this phase " + - "[phase_id: ${phase.id}] that says what to revisit and where -- never the answer.", + "add_path_step for one short refresher step in this phase [phase_id: ${phase.id}] " + + "that says what to revisit and where -- never the answer. Put it in front of this " + + "question: unlocks = [${question.id}], waits_on = [$waitsOn].", ) } } /** - * What a locked item is waiting on, named. + * Where an item sits in its phase's graph: what it comes after, whether that has locked it, and + * what finishing it opens. * - * The fix for the thing a mentor cannot get right from a flag alone: told only "locked", it - * agreed a hire could go ahead with a step their page refuses to open. A blocker inside the phase - * can be named and numbered, because the map covers the whole phase; a lock that comes from the - * phase itself is stated above and says so here rather than repeating the phase's own blockers on - * every line. + * Every item, not only locked ones. Told only about locks, the mentor read the numbers as a + * sequence and told a hire "after #6 comes #7" about items that do not depend on each other at all + * -- and could not place a new step anywhere but the end, because it had never seen an edge. + * A lock that comes from the phase itself is stated once above and said so here, rather than + * repeating the phase's own blockers on every line. */ - private fun StringBuilder.appendBlockers( + private fun StringBuilder.appendEdges( + id: UUID, locked: Boolean, blockerIds: Set, - numbers: Map, - titles: Map, + graph: PhaseGraph, ) { - if (!locked) return - - val named = blockerIds.mapNotNull { id -> - titles[id]?.let { title -> "#${numbers[id]} " + quoted(title) } + val named = blockerIds.mapNotNull { blocker -> + graph.titles[blocker]?.let { title -> "#${graph.numbers[blocker]} " + quoted(title) } } - appendLine( - if (named.isEmpty()) { - " · locked by this phase, not by anything inside it. Do not offer to start it." - } else { - " · waits on ${named.joinToString(", ")} being finished first. Do not offer to " + - "start it before then." - }, - ) + when { + named.isNotEmpty() && locked -> + appendLine( + " · comes after ${named.joinToString(", ")}, which is not finished yet -- so it is " + + "locked. Do not offer to start it before then.", + ) + named.isNotEmpty() -> appendLine(" · comes after ${named.joinToString(", ")}") + locked -> appendLine(" · locked by this phase, not by anything inside it. Do not offer to start it.") + } + val opens = graph.opens[id].orEmpty().mapNotNull { next -> graph.numbers[next]?.let { "#$it" } } + if (opens.isNotEmpty()) appendLine(" · opens: ${opens.joinToString(", ")}") } /** @@ -569,9 +626,24 @@ class BuddyPathTools( ) } - /** The one thing to talk about next, named here rather than left to the model to pick. */ - private fun StringBuilder.appendNextItem(phases: List) { + /** + * The one thing to talk about next, named here rather than left to the model to pick. + * + * A step whose checklist is done but that was never closed comes first: it is where they + * actually are, and whatever the page calls next is often locked behind it. + */ + private fun StringBuilder.appendNextItem( + phases: List, + ready: GetOnboardingStepsResponse?, + ) { append(NEWLINE) + if (ready != null) { + appendLine( + "The next thing waiting for them: finishing the step ${quoted(ready.title)} " + + "[step_id: ${ready.id}] [link: $STEP_LINK${ready.id}], whose checklist is already done.", + ) + return + } when (val next = nextItem(phases)) { null -> appendLine("Nothing on their path is open right now.") else -> appendLine("The next thing waiting for them: ${next.withIds}.") @@ -639,11 +711,14 @@ class BuddyPathTools( } /** - * The first open, unlocked item on the path, mixing steps and questions by position. + * The first open, unlocked item on the path, by the rule the hire's own page uses. + * + * The page's "next" button (`resolveNextAction`): phases in order, locked phases skipped + * entirely, then the first open unlocked *step* by position, and only when there is none, the + * first open *question*. Steps and questions carry separate positions, so mixing them by position + * -- which this used to do -- named a question as next while the page pointed at a step. * - * The same rule the hire's own page uses to pick its "next" button: phases in order, locked - * phases skipped entirely, then position order inside the phase, whichever kind of item comes - * first. Written here against the hire-facing shape rather than reusing + * Written here against the hire-facing shape rather than reusing * [OnboardingPositionReader], which predates questions being first-class and still walks steps * only -- a mentor using that would send a hire past the question their phase is actually * waiting on. Two answers to one question is a thing to reconcile, and this comment is where the @@ -666,7 +741,7 @@ class BuddyPathTools( .sortedBy { it.position } .firstOrNull { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } - if (step != null && (question == null || step.position <= question.position)) { + if (step != null) { return NextItem( plain = "the step ${quoted(step.title)}", withIds = "the step ${quoted(step.title)} [step_id: ${step.id}] " + diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt index a6a1c644..cbad94b3 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt @@ -416,6 +416,8 @@ class BuddyService( answer = proposal.answer, description = proposal.description, reason = proposal.reason, + waitsOnIds = proposal.waitsOnIds.takeIf { it.isNotEmpty() }?.map { it.toString() }, + unlocksIds = proposal.unlocksIds.takeIf { it.isNotEmpty() }?.map { it.toString() }, ), ) } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementService.kt new file mode 100644 index 00000000..6958de7d --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementService.kt @@ -0,0 +1,135 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin +import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingStep +import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingSubGraphNode +import com.sprintstart.sprintstartbackend.onboarding.model.mapper.toCreateResponse +import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse +import com.sprintstart.sprintstartbackend.onboarding.repository.OnboardingStepRepository +import com.sprintstart.sprintstartbackend.shared.annotations.Tracked +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.util.UUID + +/** + * Adds a step to a phase of the hire's own path *inside its dependency graph*, not only at the end + * of its list. + * + * A phase is not a pipeline. Its steps and questions form a graph: an item opens once everything it + * waits on is done, several can be open at once, and finishing one can open several. A step created + * with no edges is an island -- open from the start, connected to nothing in the graph view, and + * never "what comes next" however it was meant. So a step placed here says what it waits on and what + * should now wait on it. + * + * ### Inserting between + * + * When the new step sits between an item A it waits on and an item B it unlocks, the direct edge + * A → B is replaced by A → new → B. Keeping both would still be correct -- B waits on A through the + * new step anyway -- but it draws a shortcut past the step that was just put in the way, and reads + * as "B does not really need it". + * + * Only the hire's own copy is touched. The blueprint the path was copied from knows nothing of this. + */ +@Service +class OnboardingStepPlacementService( + private val onboardingStepService: OnboardingStepService, + private val onboardingStepRepository: OnboardingStepRepository, +) { + /** + * Creates the step, then connects it: it waits on [waitsOn], and every item in [unlocks] waits on + * it. Both sets must be items of the same phase, and no item in [unlocks] may be something the + * new step would itself (transitively) wait on -- that would be a cycle, and a cycle is a phase + * nobody can ever finish. + * + * One transaction, so a step never exists half-connected. + */ + @Transactional + @Tracked("Creating a connected onboarding step for user") + fun createConnectedStepForMe( + authId: String, + phaseId: UUID, + request: CreateOnboardingStepRequest, + origin: StepOrigin, + waitsOn: Set, + unlocks: Set, + ): CreateOnboardingStepResponse { + val created = onboardingStepService.createOnboardingStepForMe(authId, phaseId, request, origin) + val step = onboardingStepRepository + .findById(created.id) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "No step found with id: ${created.id}") } + + val phase = step.phase + val nodes: Map = + (phase.steps.filter { it.id != step.id } + phase.checkQuestions).associateBy { it.id } + + val before = waitsOn.map { nodes[it] ?: notInPhase(it) } + val after = unlocks.map { nodes[it] ?: notInPhase(it) } + + if (before.any { it.id in unlocks } || + after.any { candidate -> before.any { waitsTransitivelyOn(it, candidate) } } + ) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "The new step cannot both wait on and unlock the same item", + ) + } + + step.blockedBy += before + after.forEach { node -> + node.blockedBy.removeIf { it.id in waitsOn } + node.blockedBy += step + } + placeOnCanvas(step, before, after) + + return step.toCreateResponse() + } + + /** Whether [node] waits on [candidate], directly or through anything it waits on. */ + private fun waitsTransitivelyOn(node: OnboardingSubGraphNode, candidate: OnboardingSubGraphNode): Boolean { + val seen = mutableSetOf() + val stack = ArrayDeque(listOf(node)) + while (stack.isNotEmpty()) { + val current = stack.removeLast() + if (current.id == candidate.id) return true + if (seen.add(current.id)) stack.addAll(current.blockedBy) + } + return false + } + + /** + * Puts the step where the graph view will draw it between its neighbours. + * + * Graphs here flow top to bottom, so the step goes below what it waits on and above what it + * unlocks, centred on them. Nothing to centre on leaves the coordinates empty, and the viewer + * lays it out itself. + */ + private fun placeOnCanvas( + step: OnboardingStep, + before: List, + after: List, + ) { + val above = before.mapNotNull { node -> node.graphY?.let { y -> node.graphX?.let { x -> x to y } } } + val below = after.mapNotNull { node -> node.graphY?.let { y -> node.graphX?.let { x -> x to y } } } + val neighbours = above + below + if (neighbours.isEmpty()) return + + val y = when { + above.isNotEmpty() && below.isNotEmpty() -> (above.maxOf { it.second } + below.minOf { it.second }) / 2 + above.isNotEmpty() -> above.maxOf { it.second } + ROW_GAP + else -> below.minOf { it.second } - ROW_GAP + } + step.graphX = neighbours.map { it.first }.average() + step.graphY = y + } + + private fun notInPhase(id: UUID): Nothing = + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item $id is not part of this phase") + + private companion object { + /** The vertical distance between one row of a phase graph and the next. */ + const val ROW_GAP = 180.0 + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt new file mode 100644 index 00000000..6e6d0c0a --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt @@ -0,0 +1,93 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse +import java.util.UUID + +/** + * Where in a phase's dependency graph a step the buddy adds would go, checked against the hire's own + * path before anybody sees a button. + * + * [waitsOn] are the items the new step opens after; [unlocks] are the items that will wait on the new + * step instead of on those. "Put it in as the next thing" is `waitsOn = [what they are on]`, + * `unlocks = [what currently waits on that]`. + * + * Checked here on the hire-facing shape, so a bad placement comes back to the mentor as a sentence; + * [OnboardingStepPlacementService] enforces the same rules on the entities at confirm time. + */ +internal class PathStepPlacement( + private val phase: GetOnboardingPhaseForUserResponse, + val waitsOn: Set, + val unlocks: Set, +) { + private val stepsById = phase.steps.associateBy { it.id } + private val questionsById = phase.questions.associateBy { it.id } + + /** Whether the step goes anywhere but the end of an unconnected list. */ + val isConnected: Boolean get() = waitsOn.isNotEmpty() || unlocks.isNotEmpty() + + /** A sentence for the mentor saying what is wrong with this placement, or null when it is sound. */ + fun problem(): String? { + val unknown = (waitsOn + unlocks).filterNot { it in stepsById || it in questionsById } + return when { + unknown.isNotEmpty() -> + "Some ids in waits_on or unlocks are not steps or questions of “${phase.title}”. " + + "Use the step_id and question_id values of that phase from get_my_onboarding_path." + waitsOn.any { it in unlocks } -> + "The same item is in both waits_on and unlocks. The new step goes after one set and " + + "before the other." + unlocks.any { isDone(it) } -> + "Something in unlocks is already done, so there is nothing left to put the new step " + + "in front of. Only put it before items that are still open." + unlocks.any { candidate -> waitsOn.any { waitsTransitivelyOn(it, candidate) } } -> + "That placement would make a loop: something in waits_on already waits on something " + + "in unlocks. Pick items further along for unlocks." + else -> null + } + } + + /** + * The list position the step gets: straight after the last step it waits on, else straight + * before the first step it unlocks, else the end. Keeps the list view's order close to the graph. + */ + fun position(): Int { + val after = waitsOn.mapNotNull { stepsById[it]?.position }.maxOrNull() + val before = unlocks.mapNotNull { stepsById[it]?.position }.minOrNull() + return (after?.plus(1) ?: before ?: phase.steps.size).coerceIn(0, phase.steps.size) + } + + /** What the button says about where it goes, by title. Empty when it goes nowhere in particular. */ + fun describe(): String { + val after = waitsOn.map { titleOf(it) } + val before = unlocks.map { titleOf(it) } + return listOfNotNull( + after.takeIf { it.isNotEmpty() }?.let { "after ${it.joinToString(", ")}" }, + before.takeIf { it.isNotEmpty() }?.let { "before ${it.joinToString(", ")}" }, + ).joinToString(", ") + } + + /** Whether any item in [unlocks] is a step that is already started, which placing this would re-lock. */ + fun relocksStarted(): List = + unlocks.mapNotNull { id -> stepsById[id]?.takeIf { it.status == StepStatus.IN_PROGRESS }?.let { titleOf(id) } } + + private fun isDone(id: UUID): Boolean = + stepsById[id]?.let { it.status == StepStatus.FINISHED || it.status == StepStatus.SKIPPED } + ?: (questionsById[id]?.status == QuestionStatus.PASSED) + + private fun blockersOf(id: UUID): Set = stepsById[id]?.blockerIds ?: questionsById[id]?.blockerIds.orEmpty() + + private fun waitsTransitivelyOn(node: UUID, candidate: UUID): Boolean { + val seen = mutableSetOf() + val stack = ArrayDeque(listOf(node)) + while (stack.isNotEmpty()) { + val current = stack.removeLast() + if (current == candidate) return true + if (seen.add(current)) stack.addAll(blockersOf(current)) + } + return false + } + + private fun titleOf(id: UUID): String = + "“" + (stepsById[id]?.title ?: questionsById[id]?.question ?: id.toString()) + "”" +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt index 5932e98c..2b0a328e 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -2,23 +2,15 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus -import com.sprintstart.sprintstartbackend.onboarding.external.enums.SkipStatus -import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.question.SubmitQuestionAttemptRequest -import com.sprintstart.sprintstartbackend.onboarding.model.request.skip.CreateOnboardingSkipRequest -import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.task.UpdateOnboardingTaskRequest -import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.SubmitQuestionAttemptResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.CreateOnboardingSkipResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.GetOnboardingStepSkipResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.UpdateOnboardingStepResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse @@ -53,6 +45,7 @@ import java.util.UUID class BuddyPathActionTest { private val buddyPathTools: BuddyPathTools = mockk() private val onboardingStepService: OnboardingStepService = mockk() + private val onboardingStepPlacementService: OnboardingStepPlacementService = mockk() private val onboardingTaskService: OnboardingTaskService = mockk() private val questionAttemptService: QuestionAttemptService = mockk() private val onboardingSkipService: OnboardingSkipService = mockk() @@ -64,6 +57,7 @@ class BuddyPathActionTest { private val pathActions = BuddyPathActions( buddyPathTools = buddyPathTools, onboardingStepService = onboardingStepService, + onboardingStepPlacementService = onboardingStepPlacementService, onboardingTaskService = onboardingTaskService, questionAttemptService = questionAttemptService, onboardingSkipService = onboardingSkipService, @@ -416,201 +410,6 @@ class BuddyPathActionTest { assertThat(submitted.captured.selectedOptionIds).isEmpty() } - // -- request_skip ----------------------------------------------------------------------------- - - @Test - fun `a skip request without a reason sends the mentor back to ask why`() { - // The PM decides on the reason; a request with none is one that sits. - val step = step("Set up the VPN", StepStatus.WAITING) - every { buddyPathTools.findStep(userId, step.id) } returns step - - val outcome = service.propose(call("request_skip", "step_id" to step.id.toString()), userId) - - assertThat(outcome.proposal).isNull() - assertThat(outcome.toolResult).contains("ask why they want to skip it") - } - - @Test - fun `a step already waiting on a skip decision cannot get a second request`() { - val step = step("Set up the VPN", StepStatus.WAITING).copy(skip = skip(accepted = null)) - every { buddyPathTools.findStep(userId, step.id) } returns step - - val outcome = service.propose( - call("request_skip", "step_id" to step.id.toString(), "reason" to "I already have access."), - userId, - ) - - assertThat(outcome.proposal).isNull() - assertThat(outcome.toolResult).contains("has not decided yet") - // Where they can change it instead, since a second request is not possible. - assertThat(outcome.toolResult).contains("/onboarding/${step.id}") - } - - @Test - fun `the skip proposal carries the reason, and proposing sends nothing`() { - val step = step("Set up the VPN", StepStatus.WAITING) - every { buddyPathTools.findStep(userId, step.id) } returns step - - val outcome = service.propose( - call("request_skip", "step_id" to step.id.toString(), "reason" to "I already have VPN access."), - userId, - ) - - assertThat(outcome.proposal?.label).isEqualTo("Ask your PM to skip “Set up the VPN”") - assertThat(outcome.proposal?.reason).isEqualTo("I already have VPN access.") - assertThat(outcome.toolResult).contains("Do not promise it will be accepted") - verify(exactly = 0) { onboardingSkipService.createOnboardingSkipForMe(any(), any(), any()) } - } - - @Test - fun `asking again after a decline puts the PM's comment in front of the mentor`() { - val step = step("Set up the VPN", StepStatus.WAITING) - .copy(skip = skip(accepted = false, reviewComment = "Everyone needs the company VPN.")) - every { buddyPathTools.findStep(userId, step.id) } returns step - - val outcome = service.propose( - call("request_skip", "step_id" to step.id.toString(), "reason" to "I work on-site only."), - userId, - ) - - assertThat(outcome.proposal).isNotNull() - assertThat(outcome.toolResult).contains("“Everyone needs the company VPN.”") - assertThat(outcome.toolResult).contains("addresses that") - } - - @Test - fun `a confirmed skip request goes through the hire's own skip route`() = runTest { - val step = step("Set up the VPN", StepStatus.WAITING) - every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) - every { buddyPathTools.findStep(userId, step.id) } returns step - val sent = slot() - every { onboardingSkipService.createOnboardingSkipForMe(authId, step.id, capture(sent)) } returns - CreateOnboardingSkipResponse( - id = UUID.randomUUID(), - stepId = step.id, - status = SkipStatus.PENDING, - reason = "I already have VPN access.", - createdAt = Instant.EPOCH, - ) - - val result = service.perform( - BuddyActionRequest(action = "request_skip", stepId = step.id, reason = "I already have VPN access."), - jwt, - ) - - assertThat(result.ok).isTrue() - assertThat(sent.captured.reason).isEqualTo("I already have VPN access.") - assertThat(result.message).contains("your PM will decide") - } - - @Test - fun `completing a step with a pending skip says on the button that it withdraws the request`() { - // The completion route drops a pending skip. Allowed, but never silently. - val step = step("Set up the VPN", StepStatus.IN_PROGRESS).copy(skip = skip(accepted = null)) - every { buddyPathTools.findStep(userId, step.id) } returns step - - val outcome = service.propose(call("complete_step", "step_id" to step.id.toString()), userId) - - assertThat(outcome.proposal?.label).contains("withdraws your skip request") - assertThat(outcome.toolResult).contains("finishing it withdraws that request") - } - - // -- add_path_step ---------------------------------------------------------------------------- - - @Test - fun `a step with no description is refused, because a title alone is a guess`() { - val phase = phase("Deployment") - every { buddyPathTools.findPhase(userId, phase.id) } returns phase - - val outcome = service.propose( - call("add_path_step", "phase_id" to phase.id.toString(), "title" to "Learn the deploy"), - userId, - ) - - assertThat(outcome.proposal).isNull() - assertThat(outcome.toolResult).contains("has to guess at") - } - - @Test - fun `a step already on the path is not added a second time`() { - val phase = phase("Setup", steps = listOf(step("Clone the repository", StepStatus.WAITING))) - every { buddyPathTools.findPhase(userId, phase.id) } returns phase - - val outcome = service.propose( - call( - "add_path_step", - "phase_id" to phase.id.toString(), - "title" to "clone the REPOSITORY", - "description" to "Get the code onto your machine.", - ), - userId, - ) - - assertThat(outcome.proposal).isNull() - assertThat(outcome.toolResult).contains("already a step") - } - - @Test - fun `the proposal carries the phase, the title and the description`() { - val phase = phase("Deployment") - every { buddyPathTools.findPhase(userId, phase.id) } returns phase - - val outcome = service.propose( - call( - "add_path_step", - "phase_id" to phase.id.toString(), - "title" to "Walk through a release", - "description" to "Sit with whoever cuts the next release.", - ), - userId, - ) - - assertThat(outcome.proposal?.label).isEqualTo("Add “Walk through a release” to your path") - assertThat(outcome.proposal?.phaseId).isEqualTo(phase.id) - assertThat(outcome.proposal?.description).isEqualTo("Sit with whoever cuts the next release.") - // The line that makes this safe to offer at all, stated where the model reads it. - assertThat(outcome.toolResult).contains("their PM's blueprint is untouched") - } - - @Test - fun `a confirmed step lands at the end of the phase, as a task`() = runTest { - val phase = phase("Deployment", steps = listOf(step("Read the runbook", StepStatus.FINISHED))) - every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) - every { buddyPathTools.findPhase(userId, phase.id) } returns phase - val created = slot() - every { - onboardingStepService.createOnboardingStepForMe( - authId, - phase.id, - capture(created), - // The origin, asserted by being the only stub that matches: a step the buddy added - // used to arrive labelled "Custom step by PM" -- something the hire's team requires - // -- when it was something they agreed to in a chat. - StepOrigin.BUDDY, - ) - } returns createdStep("Walk through a release") - - val result = service.perform( - BuddyActionRequest( - action = "add_path_step", - phaseId = phase.id, - title = "Walk through a release", - description = "Sit with whoever cuts the next release.", - ), - jwt, - ) - - assertThat(result.ok).isTrue() - // At the end: where a step the conversation produced belongs in somebody else's sequence is - // not something this can know, and the hire can drag it. - assertThat(created.captured.position).isEqualTo(1) - assertThat(created.captured.type).isEqualTo(StepType.TASK) - // No expected outcome: that is a promise about what the step leaves somebody able to do, - // and the mentor is not in a position to make one. - assertThat(created.captured.expectedOutcome).isEmpty() - assertThat(result.message).contains("it is yours") - } - // -- fixtures --------------------------------------------------------------------------------- private fun call(name: String, vararg args: Pair) = BuddyToolCallDto( @@ -619,17 +418,6 @@ class BuddyPathActionTest { arguments = buildJsonObject { args.forEach { (k, v) -> put(k, v) } }, ) - private fun phase(title: String, steps: List = emptyList()) = - GetOnboardingPhaseForUserResponse( - id = UUID.randomUUID(), - pathId = UUID.randomUUID(), - position = 0, - title = title, - description = "", - locked = false, - steps = steps, - ) - private fun step(title: String, status: StepStatus, locked: Boolean = false) = GetOnboardingStepsResponse( id = UUID.randomUUID(), @@ -662,15 +450,6 @@ class BuddyPathActionTest { status = status, ) - private fun skip(accepted: Boolean?, reviewComment: String? = null) = GetOnboardingStepSkipResponse( - id = UUID.randomUUID(), - stepId = UUID.randomUUID(), - reason = "I already know this.", - accepted = accepted, - reviewComment = reviewComment, - reviewedAt = null, - ) - private fun task(title: String, finished: Boolean, stepId: UUID) = GetOnboardingTaskResponse( id = UUID.randomUUID(), stepId = stepId, @@ -703,19 +482,6 @@ class BuddyPathActionTest { skip = null, ) - private fun createdStep(title: String) = CreateOnboardingStepResponse( - id = UUID.randomUUID(), - phaseId = UUID.randomUUID(), - position = 1, - title = title, - description = "", - type = StepType.TASK, - estimatedMinutes = 15, - isAiAssisted = false, - expectedOutcome = "", - status = StepStatus.WAITING, - ) - private fun graded( correct: Boolean, explanation: String? = null, diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt new file mode 100644 index 00000000..a2509f77 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt @@ -0,0 +1,448 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.SkipStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.skip.CreateOnboardingSkipRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest +import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.CreateOnboardingSkipResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.GetOnboardingStepSkipResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse +import com.sprintstart.sprintstartbackend.user.external.UserApi +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.security.oauth2.jwt.Jwt +import java.time.Instant +import java.util.Optional +import java.util.UUID + +/** + * The two path actions that change what is *on* the path rather than ticking something off it: + * asking the PM to skip a step, and adding one. Split from [BuddyPathActionTest] for size; the same + * rules hold -- nothing written before a confirm, every precondition checked before the button, and + * every refusal addressed to the mentor. + */ +class BuddyPathStepActionTest { + private val buddyPathTools: BuddyPathTools = mockk() + private val onboardingStepService: OnboardingStepService = mockk() + private val onboardingStepPlacementService: OnboardingStepPlacementService = mockk() + private val onboardingTaskService: OnboardingTaskService = mockk() + private val questionAttemptService: QuestionAttemptService = mockk() + private val onboardingSkipService: OnboardingSkipService = mockk() + private val userApi: UserApi = mockk() + + // The real path component behind a real action service: these cases are about the path actions + // *and* about BuddyActionService routing them around the project gate, and mocking the component + // would test the routing against nothing. + private val pathActions = BuddyPathActions( + buddyPathTools = buddyPathTools, + onboardingStepService = onboardingStepService, + onboardingStepPlacementService = onboardingStepPlacementService, + onboardingTaskService = onboardingTaskService, + questionAttemptService = questionAttemptService, + onboardingSkipService = onboardingSkipService, + userApi = userApi, + ) + + private val service = BuddyActionService( + taskZeroService = mockk(relaxed = true), + taskOrientationService = mockk(relaxed = true), + knowledgeBaseService = mockk(relaxed = true), + userGoalService = mockk(relaxed = true), + userApi = userApi, + attestationService = mockk(relaxed = true), + boardService = mockk(relaxed = true), + competencyPlacementService = mockk(relaxed = true), + buddyPathActions = pathActions, + ) + + private val userId = UUID.randomUUID() + private val authId = "auth|hire" + private val jwt: Jwt = mockk().also { every { it.subject } returns authId } + + // -- request_skip ----------------------------------------------------------------------------- + + @Test + fun `a skip request without a reason sends the mentor back to ask why`() { + // The PM decides on the reason; a request with none is one that sits. + val step = step("Set up the VPN", StepStatus.WAITING) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("request_skip", "step_id" to step.id.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("ask why they want to skip it") + } + + @Test + fun `a step already waiting on a skip decision cannot get a second request`() { + val step = step("Set up the VPN", StepStatus.WAITING).copy(skip = skip(accepted = null)) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose( + call("request_skip", "step_id" to step.id.toString(), "reason" to "I already have access."), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("has not decided yet") + // Where they can change it instead, since a second request is not possible. + assertThat(outcome.toolResult).contains("/onboarding/${step.id}") + } + + @Test + fun `the skip proposal carries the reason, and proposing sends nothing`() { + val step = step("Set up the VPN", StepStatus.WAITING) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose( + call("request_skip", "step_id" to step.id.toString(), "reason" to "I already have VPN access."), + userId, + ) + + assertThat(outcome.proposal?.label).isEqualTo("Ask your PM to skip “Set up the VPN”") + assertThat(outcome.proposal?.reason).isEqualTo("I already have VPN access.") + assertThat(outcome.toolResult).contains("Do not promise it will be accepted") + verify(exactly = 0) { onboardingSkipService.createOnboardingSkipForMe(any(), any(), any()) } + } + + @Test + fun `asking again after a decline puts the PM's comment in front of the mentor`() { + val step = step("Set up the VPN", StepStatus.WAITING) + .copy(skip = skip(accepted = false, reviewComment = "Everyone needs the company VPN.")) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose( + call("request_skip", "step_id" to step.id.toString(), "reason" to "I work on-site only."), + userId, + ) + + assertThat(outcome.proposal).isNotNull() + assertThat(outcome.toolResult).contains("“Everyone needs the company VPN.”") + assertThat(outcome.toolResult).contains("addresses that") + } + + @Test + fun `a confirmed skip request goes through the hire's own skip route`() = runTest { + val step = step("Set up the VPN", StepStatus.WAITING) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findStep(userId, step.id) } returns step + val sent = slot() + every { onboardingSkipService.createOnboardingSkipForMe(authId, step.id, capture(sent)) } returns + CreateOnboardingSkipResponse( + id = UUID.randomUUID(), + stepId = step.id, + status = SkipStatus.PENDING, + reason = "I already have VPN access.", + createdAt = Instant.EPOCH, + ) + + val result = service.perform( + BuddyActionRequest(action = "request_skip", stepId = step.id, reason = "I already have VPN access."), + jwt, + ) + + assertThat(result.ok).isTrue() + assertThat(sent.captured.reason).isEqualTo("I already have VPN access.") + assertThat(result.message).contains("your PM will decide") + } + + @Test + fun `completing a step with a pending skip says on the button that it withdraws the request`() { + // The completion route drops a pending skip. Allowed, but never silently. + val step = step("Set up the VPN", StepStatus.IN_PROGRESS).copy(skip = skip(accepted = null)) + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_step", "step_id" to step.id.toString()), userId) + + assertThat(outcome.proposal?.label).contains("withdraws your skip request") + assertThat(outcome.toolResult).contains("finishing it withdraws that request") + } + + // -- add_path_step ---------------------------------------------------------------------------- + + @Test + fun `a step with no description is refused, because a title alone is a guess`() { + val phase = phase("Deployment") + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + call("add_path_step", "phase_id" to phase.id.toString(), "title" to "Learn the deploy"), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("has to guess at") + } + + @Test + fun `a step already on the path is not added a second time`() { + val phase = phase("Setup", steps = listOf(step("Clone the repository", StepStatus.WAITING))) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + call( + "add_path_step", + "phase_id" to phase.id.toString(), + "title" to "clone the REPOSITORY", + "description" to "Get the code onto your machine.", + ), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("already a step") + } + + @Test + fun `the proposal carries the phase, the title and the description`() { + val phase = phase("Deployment") + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + call( + "add_path_step", + "phase_id" to phase.id.toString(), + "title" to "Walk through a release", + "description" to "Sit with whoever cuts the next release.", + ), + userId, + ) + + assertThat(outcome.proposal?.label).isEqualTo("Add “Walk through a release” to your path") + assertThat(outcome.proposal?.phaseId).isEqualTo(phase.id) + assertThat(outcome.proposal?.description).isEqualTo("Sit with whoever cuts the next release.") + // The line that makes this safe to offer at all, stated where the model reads it. + assertThat(outcome.toolResult).contains("their PM's blueprint is untouched") + } + + @Test + fun `a confirmed step with no placement lands at the end of the phase, as a task`() = runTest { + val phase = phase("Deployment", steps = listOf(step("Read the runbook", StepStatus.FINISHED))) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + val created = slot() + every { + onboardingStepPlacementService.createConnectedStepForMe( + authId, + phase.id, + capture(created), + // The origin, asserted by being the only stub that matches: a step the buddy added + // used to arrive labelled "Custom step by PM" -- something the hire's team requires + // -- when it was something they agreed to in a chat. + StepOrigin.BUDDY, + emptySet(), + emptySet(), + ) + } returns createdStep("Walk through a release") + + val result = service.perform( + BuddyActionRequest( + action = "add_path_step", + phaseId = phase.id, + title = "Walk through a release", + description = "Sit with whoever cuts the next release.", + ), + jwt, + ) + + assertThat(result.ok).isTrue() + assertThat(created.captured.position).isEqualTo(1) + assertThat(created.captured.type).isEqualTo(StepType.TASK) + // No expected outcome: that is a promise about what the step leaves somebody able to do, + // and the mentor is not in a position to make one. + assertThat(created.captured.expectedOutcome).isEmpty() + assertThat(result.message).contains("it is yours") + } + + @Test + fun `an unconnected step is proposed with a warning that it is never what comes next`() { + // Testing found a refresher the hire asked to do next floating unconnected in the graph. + val phase = phase("Deployment") + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + call( + "add_path_step", + "phase_id" to phase.id.toString(), + "title" to "Refresher", + "description" to "Reread the runbook.", + ), + userId, + ) + + assertThat(outcome.toolResult).contains("is never what comes next") + } + + @Test + fun `a step placed as the next thing goes between what it waits on and what it unlocks`() { + val current = step("Read the runbook", StepStatus.IN_PROGRESS).copy(position = 0) + val later = step("Deploy to staging", StepStatus.WAITING, locked = true) + .copy(position = 1, blockerIds = setOf(current.id)) + val phase = phase("Deployment", steps = listOf(current, later)) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + placedCall(phase.id, "Walk through a release", waitsOn = listOf(current.id), unlocks = listOf(later.id)), + userId, + ) + + assertThat(outcome.proposal?.label) + .isEqualTo("Add “Walk through a release” after “Read the runbook”, before “Deploy to staging”") + assertThat(outcome.proposal?.waitsOnIds).containsExactly(current.id) + assertThat(outcome.proposal?.unlocksIds).containsExactly(later.id) + assertThat(outcome.toolResult).contains("what it unlocks now waits on it") + } + + @Test + fun `a placement in front of something already done is refused`() { + val done = step("Read the runbook", StepStatus.FINISHED) + val phase = phase("Deployment", steps = listOf(done)) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + placedCall(phase.id, "Refresher", waitsOn = emptyList(), unlocks = listOf(done.id)), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("already done") + } + + @Test + fun `a placement that would make a loop is refused before the button`() { + // later waits on current; a new step that waits on later while unlocking current would make + // current wait on itself, and nobody could ever finish the phase. + val current = step("Read the runbook", StepStatus.WAITING) + val later = step("Deploy to staging", StepStatus.WAITING).copy(blockerIds = setOf(current.id)) + val phase = phase("Deployment", steps = listOf(current, later)) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + placedCall(phase.id, "Refresher", waitsOn = listOf(later.id), unlocks = listOf(current.id)), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("loop") + } + + @Test + fun `a confirmed placed step lands next to what it waits on and is connected`() = runTest { + val current = step("Read the runbook", StepStatus.IN_PROGRESS).copy(position = 0) + val later = step("Deploy to staging", StepStatus.WAITING).copy(position = 1, blockerIds = setOf(current.id)) + val phase = phase("Deployment", steps = listOf(current, later)) + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + val created = slot() + every { + onboardingStepPlacementService.createConnectedStepForMe( + authId, + phase.id, + capture(created), + StepOrigin.BUDDY, + setOf(current.id), + setOf(later.id), + ) + } returns createdStep("Walk through a release") + + val result = service.perform( + BuddyActionRequest( + action = "add_path_step", + phaseId = phase.id, + title = "Walk through a release", + description = "Sit with whoever cuts the next release.", + waitsOnIds = listOf(current.id), + unlocksIds = listOf(later.id), + ), + jwt, + ) + + assertThat(result.ok).isTrue() + assertThat(created.captured.position).isEqualTo(1) + } + + // -- fixtures --------------------------------------------------------------------------------- + + private fun placedCall(phaseId: UUID, title: String, waitsOn: List, unlocks: List) = + BuddyToolCallDto( + id = "c0", + name = "add_path_step", + arguments = buildJsonObject { + put("phase_id", phaseId.toString()) + put("title", title) + put("description", "What doing it involves.") + putJsonArray("waits_on") { waitsOn.forEach { add(it.toString()) } } + putJsonArray("unlocks") { unlocks.forEach { add(it.toString()) } } + }, + ) + + private fun call(name: String, vararg args: Pair) = BuddyToolCallDto( + id = "c0", + name = name, + arguments = buildJsonObject { args.forEach { (k, v) -> put(k, v) } }, + ) + + private fun phase(title: String, steps: List = emptyList()) = + GetOnboardingPhaseForUserResponse( + id = UUID.randomUUID(), + pathId = UUID.randomUUID(), + position = 0, + title = title, + description = "", + locked = false, + steps = steps, + ) + + private fun step(title: String, status: StepStatus, locked: Boolean = false) = + GetOnboardingStepsResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = 0, + title = title, + description = "", + type = StepType.TASK, + estimatedMinutes = 20, + isAiAssisted = false, + status = status, + completedAt = null, + skip = null, + locked = locked, + ) + + private fun skip(accepted: Boolean?, reviewComment: String? = null) = GetOnboardingStepSkipResponse( + id = UUID.randomUUID(), + stepId = UUID.randomUUID(), + reason = "I already know this.", + accepted = accepted, + reviewComment = reviewComment, + reviewedAt = null, + ) + + private fun createdStep(title: String) = CreateOnboardingStepResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = 1, + title = title, + description = "", + type = StepType.TASK, + estimatedMinutes = 15, + isAiAssisted = false, + expectedOutcome = "", + status = StepStatus.WAITING, + ) +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index 2e689722..df261a76 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -228,7 +228,9 @@ class BuddyPathToolsTest { val text = tools.execute(userId) assertThat(text).contains("LOCKED, cannot be started yet") - assertThat(text).contains("waits on #1 “Install the toolchain”") + assertThat(text).contains("comes after #1 “Install the toolchain”, which is not finished yet") + // And the edge the other way round, so the mentor knows what finishing #1 opens. + assertThat(text).contains("· opens: #2") assertThat(text).contains("Do not offer to start it") } @@ -294,7 +296,10 @@ class BuddyPathToolsTest { assertThat(text).contains("READY TO CLOSE") assertThat(text).contains("#1 “Clone the repository” [step_id: ${done.id}]") assertThat(text).contains("it is what #2 “Run the tests” waits on") - assertThat(text).contains("call complete_step for it in the same reply") + assertThat(text).contains("call complete_step for it in that same reply") + // Where they are first, then what it opens, then the question: never the button first. + assertThat(text).contains("do not lead with the button") + assertThat(text).contains("The next thing waiting for them: finishing the step “Clone the repository”") } @Test @@ -352,6 +357,58 @@ class BuddyPathToolsTest { assertThat(text).contains("never the answer") } + @Test + fun `the phase is described as a graph, with what each item opens and what is open now`() { + // Told only about locks, the mentor read the numbers as a sequence -- "after #6 comes #7" -- + // about items that did not depend on each other at all. + val first = step("Install the toolchain", StepStatus.FINISHED) + val left = step("Run the tests", StepStatus.WAITING, blockers = setOf(first.id)) + val right = step("Read the style guide", StepStatus.WAITING, blockers = setOf(first.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(first, left, right))) + + val text = tools.execute(userId) + + assertThat(text).contains("dependency graph, not a list") + assertThat(text).contains("· opens: #2, #3") + assertThat(text).contains("Open right now: #2, #3") + } + + @Test + fun `the next thing follows the page's rule, a step before a question`() { + // Steps and questions carry separate positions; mixing them by position named a question as + // next while the page's own button pointed at a step. + val question = question("Who runs the retro?", QuestionStatus.RETRY) + val step = step("Clone the repository", StepStatus.WAITING).copy(position = 500) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(step), questions = listOf(question))) + + assertThat(tools.execute(userId)).contains("The next thing waiting for them: the step “Clone the repository”") + } + + @Test + fun `a refresher for a missed question is placed in front of that question`() { + val before = step("Read the retro guide", StepStatus.FINISHED) + val missed = question("Who runs the retro?", QuestionStatus.RETRY).copy(blockerIds = setOf(before.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", steps = listOf(before), questions = listOf(missed))) + + val text = tools.execute(userId) + + assertThat(text).contains("unlocks = [${missed.id}], waits_on = [${before.id}]") + } + + @Test + fun `the options of a question are never narrowed down`() { + val q = question("Which meeting sets the scope?", QuestionStatus.RETRY, options = listOf("Planning", "Retro")) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", questions = listOf(q))) + + // It told a hire one option "matches the title of #1 word for word": no answer stated, and the + // question given away all the same. + assertThat(tools.execute(userId)).contains("never narrow these down for them") + } + @Test fun `a step waiting on a skip decision is marked, and is never the next thing`() { // Pushing a hire to do a step they asked to skip, or to finish it -- which withdraws the diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementServiceTest.kt new file mode 100644 index 00000000..89cc51d2 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepPlacementServiceTest.kt @@ -0,0 +1,165 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType +import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingPath +import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingPhase +import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingStep +import com.sprintstart.sprintstartbackend.onboarding.model.entity.PhaseCheckQuestion +import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse +import com.sprintstart.sprintstartbackend.onboarding.repository.OnboardingStepRepository +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.springframework.web.server.ResponseStatusException +import java.util.Optional +import java.util.UUID + +/** + * A step added to a hire's path goes *into* the phase's dependency graph. + * + * Testing found a refresher the hire asked to do next sitting unconnected in the graph view -- open + * from the start, and never what came next. These cases are about the edges, on real entities, + * because the edges are what the lock computation reads. + */ +class OnboardingStepPlacementServiceTest { + private val onboardingStepService: OnboardingStepService = mockk() + private val onboardingStepRepository: OnboardingStepRepository = mockk() + private val service = OnboardingStepPlacementService(onboardingStepService, onboardingStepRepository) + + private val authId = "auth|hire" + private val path = OnboardingPath(userId = UUID.randomUUID()) + private val phase = OnboardingPhase(path = path, position = 0, title = "Setup", description = "d") + + @Test + fun `a step put between two items replaces the edge that ran past it`() { + val current = step(0, graphX = 0.0, graphY = 0.0) + val later = step(1, graphX = 0.0, graphY = 400.0).also { it.blockedBy += current } + val added = arrange(position = 1) + + service.createConnectedStepForMe( + authId, + phase.id, + request(1), + StepOrigin.BUDDY, + waitsOn = setOf(current.id), + unlocks = setOf(later.id), + ) + + assertThat(added.blockedBy.map { it.id }).containsExactly(current.id) + // current → added → later, not current → later as well: the shortcut would read as "later + // does not really need the step that was just put in its way". + assertThat(later.blockedBy.map { it.id }).containsExactly(added.id) + // Drawn between them, in a graph that flows top to bottom. + assertThat(added.graphX).isEqualTo(0.0) + assertThat(added.graphY).isEqualTo(200.0) + } + + @Test + fun `a refresher can be put in front of a question`() { + val question = PhaseCheckQuestion( + phase = phase, + position = 0, + type = CheckQuestionType.MULTIPLE_CHOICE, + question = "Who runs the retro?", + ).also { phase.checkQuestions += it } + val added = arrange(position = 0) + + service.createConnectedStepForMe( + authId, + phase.id, + request(0), + StepOrigin.BUDDY, + waitsOn = emptySet(), + unlocks = setOf(question.id), + ) + + assertThat(question.blockedBy.map { it.id }).containsExactly(added.id) + } + + @Test + fun `a placement that would make a loop is refused`() { + val first = step(0) + val second = step(1).also { it.blockedBy += first } + arrange(position = 2) + + assertThatThrownBy { + service.createConnectedStepForMe( + authId, + phase.id, + request(2), + StepOrigin.BUDDY, + waitsOn = setOf(second.id), + unlocks = setOf(first.id), + ) + }.isInstanceOf(ResponseStatusException::class.java) + } + + @Test + fun `an item from another phase is refused`() { + arrange(position = 0) + + assertThatThrownBy { + service.createConnectedStepForMe( + authId, + phase.id, + request(0), + StepOrigin.BUDDY, + waitsOn = setOf(UUID.randomUUID()), + unlocks = emptySet(), + ) + }.isInstanceOf(ResponseStatusException::class.java) + } + + /** Stubs the plain creation to hand back a real entity in [phase], the way the repository would. */ + private fun arrange(position: Int): OnboardingStep { + val added = step(position, title = "Refresher") + every { onboardingStepService.createOnboardingStepForMe(authId, phase.id, any(), StepOrigin.BUDDY) } returns + CreateOnboardingStepResponse( + id = added.id, + phaseId = phase.id, + position = position, + title = added.title, + description = "", + type = StepType.TASK, + estimatedMinutes = 15, + isAiAssisted = false, + expectedOutcome = "", + status = StepStatus.WAITING, + ) + every { onboardingStepRepository.findById(added.id) } returns Optional.of(added) + return added + } + + private fun step( + position: Int, + title: String = "Step $position", + graphX: Double? = null, + graphY: Double? = null, + ) = OnboardingStep( + phase = phase, + position = position, + title = title, + description = "d", + type = StepType.TASK, + estimatedMinutes = 10, + expectedOutcome = "", + status = StepStatus.WAITING, + graphX = graphX, + graphY = graphY, + ).also { phase.steps += it } + + private fun request(position: Int) = CreateOnboardingStepRequest( + position = position, + title = "Refresher", + description = "What to revisit.", + type = StepType.TASK, + estimatedMinutes = 15, + expectedOutcome = "", + ) +} From 21239c9fe7d7495c317cf4b3dcbef29b0f2abb44 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 14 Sep 2026 16:55:15 +0200 Subject: [PATCH 12/22] Never add a step with no way in when the mentor names only what it unlocks Testing: the hire finished #1 and asked for a step next. The mentor passed unlocks (the question) but no waits_on, so the question was locked behind a new step that nothing led into -- open from the start, unconnected at the end of the phase. PathStepPlacement.inferred fills the entry when waits_on is missing: first what the unlocked items waited on until now (A -> B becomes A -> new -> B), else where the hire is (the step started, or the one finished most recently). The button names what it comes after, so an inference they did not mean is visible before the click, and the mentor is told it was inferred. The path read also spells out both halves of "add a step as the next thing" for the step they are on, and the refresher line for a missed question uses the same inference. Co-Authored-By: Claude Opus 5 --- .../onboarding/service/BuddyPathActions.kt | 10 +++- .../onboarding/service/BuddyPathTools.kt | 20 +++++-- .../onboarding/service/PathStepPlacement.kt | 53 +++++++++++++++++++ .../service/BuddyPathStepActionTest.kt | 51 ++++++++++++++++++ .../onboarding/service/BuddyPathToolsTest.kt | 23 ++++++++ 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index 9f488ef7..9a42223a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -377,7 +377,7 @@ class BuddyPathActions( ) val title = call.stringArg("title").trim() val description = call.stringArg("description").trim() - val placement = PathStepPlacement(phase, call.uuidListArg("waits_on"), call.uuidListArg("unlocks")) + val placement = PathStepPlacement.inferred(phase, call.uuidListArg("waits_on"), call.uuidListArg("unlocks")) val refusal = when { title.isBlank() -> "No title was provided. Say what the step is, in a few words." @@ -399,6 +399,12 @@ class BuddyPathActions( " It is not connected to anything, so it is open straight away and is never what comes " + "next — if it belongs somewhere in their path, pass waits_on and unlocks." } + val entry = if (placement.entryInferred) { + " You passed no waits_on, so it opens after what the path says it should (the button " + + "names it) -- if that is not where they meant, offer it again with waits_on." + } else { + "" + } val relocks = placement .relocksStarted() .takeIf { it.isNotEmpty() } @@ -412,7 +418,7 @@ class BuddyPathActions( "“${phase.title}” of their own path. They see a confirm button; nothing is added " + "unless they click it. This changes their copy only — their PM's blueprint is " + "untouched — and they can edit or remove it afterwards. Say what the step is for " + - "and where it goes before you offer it.$connection$relocks", + "and where it goes before you offer it.$connection$entry$relocks", proposal = BuddyActionService.BuddyActionProposal( action = type.toolName, label = if (where.isEmpty()) "Add “$title” to your path" else "Add “$title” $where", diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index 1474e992..b15f5aad 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -425,6 +425,19 @@ class BuddyPathTools( if (!phase.locked && openNow.isNotEmpty()) { appendLine("Open right now: " + openNow.joinToString(", ") { "#${numbers[it]}" }) } + // The two halves of "add a step as the next thing", worked out rather than described: told + // how to place a step, the mentor passed what it unlocks and left out what it comes after. + PathStepPlacement.anchorOf(phase)?.let { anchor -> + val done = steps + .filter { it.status == StepStatus.FINISHED || it.status == StepStatus.SKIPPED } + .map { it.id } + .toSet() + questions.filter { it.status == QuestionStatus.PASSED }.map { it.id } + val after = opensIn(phase)[anchor].orEmpty().filterNot { it in done } + appendLine( + "To add a step as the next thing after #${numbers[anchor]}, where they are: pass BOTH " + + "waits_on = [$anchor] and unlocks = [${after.joinToString(", ")}].", + ) + } } /** @@ -546,9 +559,10 @@ class BuddyPathTools( // the material in the conversation comes first; a refresher step is for when what they // missed is more than one explanation, so it is still there tomorrow. if (question.status == QuestionStatus.RETRY) { - // Placed before the question, so the refresher is what opens it: waits_on takes over - // whatever the question waits on now, and the question waits on the refresher instead. - val waitsOn = question.blockerIds.joinToString(", ") + // Placed before the question, so the refresher is what opens it, and after what the + // question waits on now -- or, when that is nothing, after where the hire is. The same + // inference the action applies, spelled out so the mentor passes both halves. + val waitsOn = PathStepPlacement.inferred(phase, emptySet(), setOf(question.id)).waitsOn.joinToString(", ") appendLine( " · they got this wrong before, so the material behind it did not land. Go through it " + "with them first. If what they missed is bigger than one explanation, offer " + diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt index 6e6d0c0a..2385c631 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt @@ -20,6 +20,8 @@ internal class PathStepPlacement( private val phase: GetOnboardingPhaseForUserResponse, val waitsOn: Set, val unlocks: Set, + /** Whether [waitsOn] was worked out here rather than passed by the mentor. See [inferred]. */ + val entryInferred: Boolean = false, ) { private val stepsById = phase.steps.associateBy { it.id } private val questionsById = phase.questions.associateBy { it.id } @@ -90,4 +92,55 @@ internal class PathStepPlacement( private fun titleOf(id: UUID): String = "“" + (stepsById[id]?.title ?: questionsById[id]?.question ?: id.toString()) + "”" + + companion object { + /** + * A placement with its entry filled in when the mentor left it out. + * + * Testing had the mentor pass only `unlocks` for a step the hire asked to do next: the + * question after it was locked behind the new step, but nothing led *into* the new step, so + * it sat open from the start with no edge in -- a graph that no longer reads as one. The + * entry is not something to leave to a model that reliably fills in half of a pair, so when + * [waitsOn] is empty it is worked out, in this order: + * + * 1. **What the unlocked items waited on until now.** Putting a step in front of B means + * taking over B's incoming edges: A → B becomes A → new → B. + * 2. **Where the hire is**: the step they have started, or else the one they finished most + * recently. An item with no edges in (the first of a phase) has nothing to take over, and + * "as the next thing" means after what they are doing. + * + * Nothing at all only when neither exists, or when using it would make a loop -- a phase the + * hire has not touched, where a step really can stand at the start. The button names what it + * comes after either way, so an inference the hire did not mean is visible before the click. + */ + fun inferred( + phase: GetOnboardingPhaseForUserResponse, + waitsOn: Set, + unlocks: Set, + ): PathStepPlacement { + if (waitsOn.isNotEmpty()) return PathStepPlacement(phase, waitsOn, unlocks) + + val items = phase.steps.map { it.id to it.blockerIds } + phase.questions.map { it.id to it.blockerIds } + val inPhase = items.map { it.first }.toSet() + val inherited = items + .filter { it.first in unlocks } + .flatMap { it.second } + .filter { it in inPhase && it !in unlocks } + .toSet() + if (inherited.isNotEmpty()) return PathStepPlacement(phase, inherited, unlocks, entryInferred = true) + + val anchor = anchorOf(phase)?.takeIf { it !in unlocks } + ?: return PathStepPlacement(phase, emptySet(), unlocks) + val anchored = PathStepPlacement(phase, setOf(anchor), unlocks, entryInferred = true) + return if (anchored.problem() == null) anchored else PathStepPlacement(phase, emptySet(), unlocks) + } + + /** The step the hire is on: the one started, else the one finished most recently. */ + fun anchorOf(phase: GetOnboardingPhaseForUserResponse): UUID? = + phase.steps.firstOrNull { it.status == StepStatus.IN_PROGRESS }?.id + ?: phase.steps + .filter { it.status == StepStatus.FINISHED && it.completedAt != null } + .maxByOrNull { it.completedAt!! } + ?.id + } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt index a2509f77..27204621 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt @@ -1,5 +1,7 @@ package com.sprintstart.sprintstartbackend.onboarding.service +import com.sprintstart.sprintstartbackend.onboarding.external.enums.CheckQuestionType +import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.SkipStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepOrigin import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus @@ -9,6 +11,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyAc import com.sprintstart.sprintstartbackend.onboarding.model.request.skip.CreateOnboardingSkipRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.CreateOnboardingSkipResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.GetOnboardingStepSkipResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.CreateOnboardingStepResponse @@ -308,6 +311,44 @@ class BuddyPathStepActionTest { assertThat(outcome.toolResult).contains("what it unlocks now waits on it") } + @Test + fun `a step given only what it unlocks takes over that item's way in`() { + // Testing: the hire finished #1 and asked for a step next; the mentor passed only unlocks, and + // the new step was locked-in behind nothing -- open from the start, no edge into it. + val current = step("Read the runbook", StepStatus.FINISHED) + val question = question("What does the runbook cover?", QuestionStatus.OPEN) + .copy(blockerIds = setOf(current.id)) + val phase = phase("Deployment", steps = listOf(current)).copy(questions = listOf(question)) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + placedCall(phase.id, "Refresher", waitsOn = emptyList(), unlocks = listOf(question.id)), + userId, + ) + + assertThat(outcome.proposal?.waitsOnIds).containsExactly(current.id) + assertThat(outcome.proposal?.label).contains("after “Read the runbook”") + assertThat(outcome.toolResult).contains("You passed no waits_on") + } + + @Test + fun `a step in front of an item nothing leads into opens after where the hire is`() { + // The question had no edge in, so there is nothing to take over: "as the next thing" means + // after the step they just finished. + val finished = step("Read the runbook", StepStatus.FINISHED).copy(completedAt = Instant.EPOCH) + val question = question("What does the runbook cover?", QuestionStatus.OPEN) + val phase = phase("Deployment", steps = listOf(finished)).copy(questions = listOf(question)) + every { buddyPathTools.findPhase(userId, phase.id) } returns phase + + val outcome = service.propose( + placedCall(phase.id, "Refresher", waitsOn = emptyList(), unlocks = listOf(question.id)), + userId, + ) + + assertThat(outcome.proposal?.waitsOnIds).containsExactly(finished.id) + assertThat(outcome.proposal?.unlocksIds).containsExactly(question.id) + } + @Test fun `a placement in front of something already done is refused`() { val done = step("Read the runbook", StepStatus.FINISHED) @@ -408,6 +449,16 @@ class BuddyPathStepActionTest { steps = steps, ) + private fun question(text: String, status: QuestionStatus) = GetOnboardingQuestionForUserResponse( + id = UUID.randomUUID(), + phaseId = UUID.randomUUID(), + position = 0, + type = CheckQuestionType.SHORT_TEXT, + question = text, + options = emptyList(), + status = status, + ) + private fun step(title: String, status: StepStatus, locked: Boolean = false) = GetOnboardingStepsResponse( id = UUID.randomUUID(), diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index df261a76..641abeac 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -398,6 +398,29 @@ class BuddyPathToolsTest { assertThat(text).contains("unlocks = [${missed.id}], waits_on = [${before.id}]") } + @Test + fun `adding a step as the next thing is spelled out with both halves`() { + // Told how to place a step, the mentor passed what it unlocks and left out what it comes + // after. So the path read hands over both, worked out from where the hire is. + val current = step("Read the runbook", StepStatus.IN_PROGRESS) + val next = step("Deploy to staging", StepStatus.WAITING, locked = true, blockers = setOf(current.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Deployment", steps = listOf(current, next))) + + assertThat(tools.execute(userId)) + .contains("pass BOTH waits_on = [${current.id}] and unlocks = [${next.id}]") + } + + @Test + fun `a refresher in front of a question nothing leads into opens after where the hire is`() { + val finished = step("Read the retro guide", StepStatus.FINISHED).copy(completedAt = Instant.EPOCH) + val missed = question("Who runs the retro?", QuestionStatus.RETRY) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", steps = listOf(finished), questions = listOf(missed))) + + assertThat(tools.execute(userId)).contains("unlocks = [${missed.id}], waits_on = [${finished.id}]") + } + @Test fun `the options of a question are never narrowed down`() { val q = question("Which meeting sets the scope?", QuestionStatus.RETRY, options = listOf("Planning", "Retro")) From b95aa35a3a0185110021a2195ca19adad31178eb Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 14 Sep 2026 18:09:51 +0200 Subject: [PATCH 13/22] Close three gaps the buddy's path actions opened up Deleting a step left the edges that pointed at it: the join rows outlived the step, so the delete failed or the items after it stayed locked behind a step nobody could finish. Steps the buddy adds now sit inside the graph and the hire is told they can delete them, so both delete paths bridge over the step instead (A -> X -> B becomes A -> B). Adding a step to a finished phase would reopen it and lock every phase waiting on it, including the one the hire is working in; add_path_step refuses that and points at the current phase. complete_task refuses a line of a locked step at proposal and at confirm, since the task route does not check locks. A blank expected outcome is no longer returned as [""], which rendered as an empty bullet on the step page for every step a hire or their buddy added. Co-Authored-By: Claude Opus 5 --- .../model/mapper/OnboardingStepMapper.kt | 8 +++-- .../onboarding/service/BuddyPathActions.kt | 36 ++++++++++++++++++- .../onboarding/service/BuddyPathTools.kt | 4 +++ .../service/OnboardingStepService.kt | 26 ++++++++++++++ .../onboarding/service/BuddyPathActionTest.kt | 19 +++++++++- .../service/BuddyPathStepActionTest.kt | 29 ++++++++++++++- .../service/OnboardingStepServiceTest.kt | 32 +++++++++++++++++ 7 files changed, 149 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt index 2ed6734b..24eaf90a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/OnboardingStepMapper.kt @@ -21,7 +21,9 @@ fun OnboardingStep.toGetAllResponse(locked: Boolean = false): GetOnboardingSteps estimatedMinutes = this.estimatedMinutes, isAiAssisted = this.aiAssisted, origin = this.origin, - expectedOutcomes = listOf(this.expectedOutcome), + // A step with no expected outcome -- one a hire or their buddy added -- stores "", and a + // list holding "" rendered as an empty "Expected outcomes" bullet on the step page. + expectedOutcomes = listOf(this.expectedOutcome).filter { it.isNotBlank() }, status = this.status, startedAt = this.startedAt, completedAt = this.completedAt, @@ -45,7 +47,9 @@ fun OnboardingStep.toGetResponse(): GetOnboardingStepResponse { type = this.type, isAiAssisted = this.aiAssisted, origin = this.origin, - expectedOutcomes = listOf(this.expectedOutcome), + // A step with no expected outcome -- one a hire or their buddy added -- stores "", and a + // list holding "" rendered as an empty "Expected outcomes" bullet on the step page. + expectedOutcomes = listOf(this.expectedOutcome).filter { it.isNotBlank() }, tasks = this.tasks.map { task -> task.toGetAllResponse() }, resources = this.resources.map { resource -> resource.toGetAllResponse() }, status = this.status, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index 9a42223a..0c340e89 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -14,6 +14,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.request.skip.CreateOn import com.sprintstart.sprintstartbackend.onboarding.model.request.step.CreateOnboardingStepRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.task.UpdateOnboardingTaskRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.buddy.BuddyActionResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.question.QuestionOptionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse @@ -387,7 +388,7 @@ class BuddyPathActions( phase.steps.any { it.title.trim().equals(title, ignoreCase = true) } -> "“$title” is already a step of “${phase.title}”, so nothing needs adding. Point them " + "at the one that is there." - else -> placement.problem() + else -> reopensFinishedPhase(userId, phase) ?: placement.problem() } if (refusal != null) return refused(refusal) @@ -432,6 +433,26 @@ class BuddyPathActions( ) } + /** + * Why adding a step to [phase] would take away something the hire already has, or null. + * + * A finished phase is what unlocked every phase that waits on it. A new open step makes it + * unfinished again, and those phases lock -- a hire asking for one extra step would find the phase + * they were working in shut. The page's own "add step" has the same effect, which is exactly why + * the mentor should not reach for it without knowing. + */ + private fun reopensFinishedPhase(userId: UUID, phase: GetOnboardingPhaseForUserResponse): String? { + val finished = (phase.steps.isNotEmpty() || phase.questions.isNotEmpty()) && + phase.steps.all { it.status == StepStatus.FINISHED || it.status == StepStatus.SKIPPED } && + phase.questions.all { it.status == QuestionStatus.PASSED } + if (!finished) return null + val waiting = buddyPathTools.phasesOf(userId).filter { phase.id in it.blockerIds } + if (waiting.isEmpty()) return null + return "“${phase.title}” is finished, and ${waiting.joinToString(", ") { "“${it.title}”" }} " + + "waits on it: a new step there would lock that again until it is done. Put the step in the " + + "phase they are standing in instead." + } + /** * Offers to tick one line off the checklist of a step. * @@ -462,6 +483,13 @@ class BuddyPathActions( "offering it again.", ) } + // The task route does not check locks; the page does, by never opening a locked step. + if (buddyPathTools.findStep(userId, task.stepId)?.locked == true) { + return refused( + "The step “${task.title}” belongs to is locked, so nothing on its checklist can be " + + "ticked yet. Say what the step is waiting on instead.", + ) + } return BuddyActionService.ProposeOutcome( toolResult = "Proposed to the hire: tick “${task.title}” off their checklist. They see a " + @@ -501,6 +529,12 @@ class BuddyPathActions( return BuddyActionResponse(ok = false, message = "No checklist line was proposed to tick off.") } val task = onboardingTaskService.getOnboardingTaskForMe(authId, taskId) + if (buddyPathTools.findStep(resolveUserId(authId), task.stepId)?.locked == true) { + return BuddyActionResponse( + ok = false, + message = "That step is locked right now, so its checklist can't be ticked yet.", + ) + } onboardingTaskService.updateOnboardingTaskForMe( authId, taskId, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index b15f5aad..1bed1642 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -291,6 +291,10 @@ class BuddyPathTools( fun findPhase(userId: UUID, phaseId: UUID): GetOnboardingPhaseForUserResponse? = onboardingPathService.findPathForUserId(userId)?.phases?.firstOrNull { it.id == phaseId } + /** Every phase of the hire's own path, or empty when they have none. */ + fun phasesOf(userId: UUID): List = + onboardingPathService.findPathForUserId(userId)?.phases.orEmpty() + /** One step of the hire's own path by id, or null. See [findPhase] for why it resolves this way. */ fun findStep(userId: UUID, stepId: UUID): GetOnboardingStepsResponse? = onboardingPathService diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt index 633a8a74..fbb4fbc7 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepService.kt @@ -290,6 +290,7 @@ class OnboardingStepService( .findAllByPhaseIdAndPositionGreaterThan(step.phase.id, step.position) stepsToShift.forEach { it.position -= 1 } + bridgeOverInGraph(step) onboardingStepRepository.delete(step) } @@ -414,11 +415,36 @@ class OnboardingStepService( .findAllByPhaseIdAndPositionGreaterThan(step.phase.id, step.position) stepsToShift.forEach { it.position -= 1 } + bridgeOverInGraph(step) onboardingStepRepository.delete(step) } // ========================== Helper Methods ========================== + /** + * Takes a step out of its phase's dependency graph before it is deleted, joining up what it sat + * between. + * + * Blocker edges are a many-to-many between subgraph nodes, and deleting a step removes only the + * rows it owns -- the ones saying what *it* waits on. The rows saying what waits on *it* belong to + * the other nodes and outlived it: the delete failed on the join table, or the items after it + * stayed locked behind a step nobody could finish any more. Steps a hire adds with their buddy are + * placed inside the graph, and they are told they can delete them, so this is not a corner case. + * + * Bridged rather than only cut: for A -> X -> B, deleting X leaves A -> B, so B still opens after + * what it opened after before X was put in the way -- instead of suddenly opening at once. + */ + private fun bridgeOverInGraph(step: OnboardingStep) { + val phase = step.phase + val dependents = (phase.steps + phase.checkQuestions) + .filter { node -> node.id != step.id && node.blockedBy.any { it.id == step.id } } + dependents.forEach { node -> + node.blockedBy.removeIf { it.id == step.id } + node.blockedBy += step.blockedBy.filter { it.id != node.id } + } + step.blockedBy.clear() + } + /** * Makes room for a new step at the requested position by shifting all existing * steps at that position or after it one position to the right. diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt index 2b0a328e..24ff880f 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -185,6 +185,20 @@ class BuddyPathActionTest { // -- complete_task ---------------------------------------------------------------------------- + @Test + fun `a line of a locked step is not offered`() { + // The task route does not check locks: only the page does, by never opening the step. + val step = step("Deploy to staging", StepStatus.WAITING, locked = true) + val task = task("Run the deploy script", finished = false, stepId = step.id) + every { onboardingTaskService.getOnboardingTaskById(task.id) } returns task + every { buddyPathTools.findStep(userId, step.id) } returns step + + val outcome = service.propose(call("complete_task", "task_id" to task.id.toString()), userId) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("is locked") + } + @Test fun `a line of a step on the hire's path can be ticked off on its own`() { // The finer claim, and the reason both exist: "I have done the first two" is not a finished @@ -229,8 +243,11 @@ class BuddyPathActionTest { @Test fun `a confirmed tick writes the line back with everything else unchanged`() = runTest { - val task = task("Install git", finished = false, stepId = UUID.randomUUID()) + val step = step("Clone the repository", StepStatus.IN_PROGRESS) + val task = task("Install git", finished = false, stepId = step.id) every { onboardingTaskService.getOnboardingTaskForMe(authId, task.id) } returns task + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { buddyPathTools.findStep(userId, step.id) } returns step val written = slot() every { onboardingTaskService.updateOnboardingTaskForMe(authId, task.id, capture(written)) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt index 27204621..6c8cc5fc 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt @@ -40,7 +40,10 @@ import java.util.UUID * every refusal addressed to the mentor. */ class BuddyPathStepActionTest { - private val buddyPathTools: BuddyPathTools = mockk() + // No other phase waits on the ones in these cases unless a case says so. + private val buddyPathTools: BuddyPathTools = mockk { + every { phasesOf(any()) } returns emptyList() + } private val onboardingStepService: OnboardingStepService = mockk() private val onboardingStepPlacementService: OnboardingStepPlacementService = mockk() private val onboardingTaskService: OnboardingTaskService = mockk() @@ -349,6 +352,30 @@ class BuddyPathStepActionTest { assertThat(outcome.proposal?.unlocksIds).containsExactly(question.id) } + @Test + fun `a step is not added to a finished phase that another phase waits on`() { + // A finished phase is what unlocked the phases after it; a new open step there would lock + // the phase the hire is actually working in. + val done = step("Read the runbook", StepStatus.FINISHED) + val finished = phase("Setup", steps = listOf(done)) + val current = phase("First change").copy(blockerIds = setOf(finished.id)) + every { buddyPathTools.findPhase(userId, finished.id) } returns finished + every { buddyPathTools.phasesOf(userId) } returns listOf(finished, current) + + val outcome = service.propose( + call( + "add_path_step", + "phase_id" to finished.id.toString(), + "title" to "Refresher", + "description" to "Reread the runbook.", + ), + userId, + ) + + assertThat(outcome.proposal).isNull() + assertThat(outcome.toolResult).contains("would lock that again") + } + @Test fun `a placement in front of something already done is refused`() { val done = step("Read the runbook", StepStatus.FINISHED) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepServiceTest.kt index d4da5f00..8958ff38 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingStepServiceTest.kt @@ -356,6 +356,38 @@ class OnboardingStepServiceTest { verify(exactly = 1) { onboardingStepRepository.delete(step) } } + @Test + fun `joins up the graph around a deleted step`() { + // A -> X -> B. Without this the edge B -> X outlived X: the delete failed on the join + // table, or B stayed locked behind a step nobody could finish. + val phase = makePhase() + + fun node(id: UUID, title: String) = OnboardingStep( + id = id, + phase = phase, + position = 0, + title = title, + description = "d", + type = StepType.DOCUMENT, + estimatedMinutes = 5, + expectedOutcome = "", + status = StepStatus.WAITING, + ).also { phase.steps += it } + val a = node(UUID.randomUUID(), "A") + val x = node(stepId, "X").also { it.blockedBy += a } + val b = node(UUID.randomUUID(), "B").also { it.blockedBy += x } + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { onboardingStepRepository.findByIdAndPhasePathUserId(stepId, userId) } returns Optional.of(x) + every { onboardingStepRepository.findAllByPhaseIdAndPositionGreaterThan(phase.id, 0) } returns + mutableListOf() + every { onboardingStepRepository.delete(x) } just runs + + service.deleteOnboardingStepForMe(authId, stepId) + + assertEquals(setOf(a.id), b.blockedBy.map { it.id }.toSet()) + assertEquals(emptySet(), x.blockedBy.map { it.id }.toSet()) + } + @Test fun `throws 404 when step not found`() { every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) From 9ee365ddbe615cf68fb400e61d189f0792451819 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Tue, 15 Sep 2026 12:53:05 +0200 Subject: [PATCH 14/22] Retire the old onboarding: Task 0, the ramp and the path card Onboarding is the path a PM's blueprint prescribes, and the buddy is the tutor along it. Beside it the backend still ran a second onboarding: a Task 0 handed out on first read, a ramp that ended onboarding at "autonomy", and a board card from joining to a first accepted contribution that said "onboarding ended" on its own terms. - Task 0 is gone: TaskZeroService and its controller, the assignment entity, the claim_task_zero buddy action. The current task is only ever the goal the hire claimed, and the task packet now reads that (it used to work for Task 0 alone). - RampService, the autonomy milestone and the ramp responses are gone. Nothing called the ramp; autonomy was only ever read, never written. - The PATH_TO_FIRST_CONTRIBUTION board card is gone. Stored rows of it would fail every board read, so RetiredBoardCardCleanup deletes them on startup. - The buddy reads the path first, then setup, then work. Metrics are described as how their work is going, not their onboarding, and the metrics chip asks that. - V19 documents the cleanup. task_zero_eligible stays mapped until the column has a default everywhere, or new proposals fail to insert. Arrival steps, claiming work, the task packet, metrics and competencies stay; they are just no longer called the onboarding. Refs #311 Co-Authored-By: Claude Opus 5 --- .../ingestion/service/AssignedIssueReader.kt | 4 +- .../onboarding/controller/BuddyController.kt | 7 +- .../controller/TaskZeroController.kt | 113 ------ .../external/enums/BoardCardKind.kt | 12 +- .../external/enums/BuddyActionType.kt | 1 - .../onboarding/external/enums/RampStage.kt | 37 -- .../onboarding/external/enums/Rigor.kt | 2 +- .../listener/RetiredBoardCardCleanup.kt | 38 ++ .../listener/UserDeletedListener.kt | 6 - .../model/entity/AutonomyMilestone.kt | 44 -- .../model/entity/StarterWorkTaskProposal.kt | 11 +- .../model/entity/TaskZeroAssignment.kt | 37 -- .../mapper/StarterWorkTaskProposalMapper.kt | 1 - .../SetTaskZeroEligibilityRequest.kt | 8 - .../attestation/AttestationResponse.kt | 2 +- .../model/response/board/BoardResponses.kt | 44 -- .../response/buddy/BuddyActionResponse.kt | 4 +- .../metrics/OnboardingMetricsResponse.kt | 20 +- .../model/response/ramp/RampResponses.kt | 39 -- .../starterwork/MyTaskZeroResponse.kt | 23 -- .../starterwork/StarterWorkResponses.kt | 2 - .../repository/AutonomyMilestoneRepository.kt | 15 - .../repository/BoardCardRepository.kt | 21 + .../StarterWorkTaskProposalRepository.kt | 3 - .../TaskZeroAssignmentRepository.kt | 20 - .../onboarding/service/AttestationService.kt | 2 +- .../onboarding/service/BoardService.kt | 51 +-- .../onboarding/service/BuddyActionService.kt | 36 +- .../service/BuddySuggestionService.kt | 17 +- .../onboarding/service/BuddyToolExecutor.kt | 57 ++- .../service/CompetencyPlacementService.kt | 3 +- .../onboarding/service/ContributionService.kt | 8 +- .../onboarding/service/CurrentTaskReader.kt | 31 +- .../service/OnboardingMetricsService.kt | 21 +- .../onboarding/service/RampService.kt | 282 ------------- .../service/TaskOrientationService.kt | 15 +- .../onboarding/service/TaskZeroService.kt | 157 -------- .../V19__retire_legacy_onboarding.sql | 17 + .../service/AssignedIssueReaderTest.kt | 2 +- .../controller/BoardControllerTest.kt | 22 +- .../controller/BuddyControllerTest.kt | 8 +- .../OnboardingMetricsControllerTest.kt | 2 - .../controller/StarterWorkControllerTest.kt | 1 - .../controller/TaskZeroControllerTest.kt | 116 ------ .../listener/UserDeletedListenerTest.kt | 8 - .../onboarding/service/BoardServiceTest.kt | 166 ++------ .../service/BuddyActionServiceTest.kt | 71 +--- .../onboarding/service/BuddyBoardToolsTest.kt | 4 +- .../onboarding/service/BuddyPathActionTest.kt | 1 - .../service/BuddyPathStepActionTest.kt | 1 - .../onboarding/service/BuddyServiceTest.kt | 14 +- .../service/BuddySuggestionServiceTest.kt | 27 +- .../service/BuddyToolExecutorTest.kt | 29 +- .../service/OnboardingMetricsServiceTest.kt | 9 - .../service/ProjectAttentionServiceTest.kt | 2 - .../onboarding/service/RampServiceTest.kt | 381 ------------------ .../service/TaskOrientationServiceTest.kt | 27 +- .../onboarding/service/TaskZeroServiceTest.kt | 202 ---------- 58 files changed, 277 insertions(+), 2027 deletions(-) delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroController.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/RampStage.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/RetiredBoardCardCleanup.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/AutonomyMilestone.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/TaskZeroAssignment.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/ramp/RampResponses.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/MyTaskZeroResponse.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/AutonomyMilestoneRepository.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/TaskZeroAssignmentRepository.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampService.kt delete mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroService.kt create mode 100644 src/main/resources/db/migration/V19__retire_legacy_onboarding.sql delete mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroControllerTest.kt delete mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampServiceTest.kt delete mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroServiceTest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReader.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReader.kt index 56560314..46873827 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReader.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReader.kt @@ -138,8 +138,8 @@ class AssignedIssueReader( * * The tracker's version of a review asking for changes: the assignee said it was ready, and * somebody who was not them moved it back. Counting every status change instead would count the - * normal flow of work as rework, and reporting a flat zero would hand every tracked issue the - * clean-run half of the autonomy signal without it having been earned. + * normal flow of work as rework, and reporting a flat zero would hand every tracked issue a + * clean run it never earned. */ private fun returnedCount(statusChanges: List, assignee: String): Int { var returned = 0 diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt index df36c69d..23ad0bc1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt @@ -177,9 +177,10 @@ class BuddyController( @Operation( summary = "Confirm a buddy-proposed action", - description = "Runs an action the buddy proposed, on the hire's explicit confirmation — start Task 0, " + - "open the task packet, log buddy contact, or flag a question to the PM. The action is re-scoped to " + - "the caller server-side. Returns a single line to relay; a handled failure is `ok = false`, not an error.", + description = "Runs an action the buddy proposed, on the hire's explicit confirmation — claim a " + + "task, open the task packet, tick off a step of their path, or flag a question to the PM. The " + + "action is re-scoped to the caller server-side. Returns a single line to relay; a handled " + + "failure is `ok = false`, not an error.", ) @ApiResponses( value = [ diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroController.kt deleted file mode 100644 index c0b81be4..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroController.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.controller - -import com.sprintstart.sprintstartbackend.onboarding.model.request.starterwork.SetTaskZeroEligibilityRequest -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.MyTaskZeroResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.StarterWorkTaskProposalResponse -import com.sprintstart.sprintstartbackend.onboarding.service.TaskZeroService -import com.sprintstart.sprintstartbackend.user.external.UserApi -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag -import org.springframework.http.HttpStatus -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.security.oauth2.jwt.Jwt -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController -import org.springframework.web.server.ResponseStatusException -import java.util.UUID - -/** - * Task 0: the trivial first task a hire is auto-assigned once their environment is ready. - * - * A PM flags which live starter-work tasks are suitable; a hire reads (and, on first read, is - * assigned) their own, and may undo it. Assignment is deliberately a hire-side read side effect - * rather than a background job, so it covers derived readiness too — a PM viewing the metrics never - * assigns anything. - */ -@RestController -@RequestMapping("/api/v1/onboarding") -@Tag( - name = "Onboarding - Task 0", - description = "The trivial first task that proves the branch → PR → review → merge loop", -) -class TaskZeroController( - private val taskZeroService: TaskZeroService, - private val userApi: UserApi, -) { - @Operation( - summary = "Flag an approved task as suitable for Task 0", - description = "A PM's decision that this live starter-work task is small and safe enough " + - "to be a hire's first task. Only valid on an approved proposal.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "Eligibility updated"), - ApiResponse(responseCode = "403", description = "Insufficient role"), - ApiResponse(responseCode = "404", description = "No such starter-work task"), - ApiResponse(responseCode = "409", description = "The task is not approved"), - ], - ) - @ResponseStatus(HttpStatus.OK) - @PostMapping("/starter-work/{proposalId}/task-zero") - @PreAuthorize("hasAnyRole('ADMIN', 'PM')") - fun setEligibility( - @PathVariable proposalId: UUID, - @RequestBody request: SetTaskZeroEligibilityRequest, - ): StarterWorkTaskProposalResponse = taskZeroService.setEligibility(proposalId, request.eligible) - - @Operation( - summary = "My Task 0 on a project", - description = "Assigns one automatically if my environment is ready and none is assigned yet. " + - "Not-ready, and ready-but-nothing-eligible, are ordinary states rather than errors.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "Task 0 state returned"), - ApiResponse(responseCode = "401", description = "Authentication required"), - ApiResponse(responseCode = "404", description = "You are not a member of that project"), - ], - ) - @ResponseStatus(HttpStatus.OK) - @GetMapping("/me/task-zero") - @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") - fun getMyTaskZero( - @Parameter(hidden = true) - @AuthenticationPrincipal jwt: Jwt, - @RequestParam projectId: UUID, - ): MyTaskZeroResponse = taskZeroService.getForHire(resolveUserId(jwt), projectId) - - @Operation( - summary = "Undo my Task 0 assignment", - description = "Frees the task for someone else. A no-op when nothing is assigned; " + - "earns nothing, so un-earns nothing.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "204", description = "Assignment removed (or there was none)"), - ApiResponse(responseCode = "401", description = "Authentication required"), - ], - ) - @ResponseStatus(HttpStatus.NO_CONTENT) - @DeleteMapping("/me/task-zero") - @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") - fun unassignMyTaskZero( - @Parameter(hidden = true) - @AuthenticationPrincipal jwt: Jwt, - @RequestParam projectId: UUID, - ) = taskZeroService.unassign(resolveUserId(jwt), projectId) - - private fun resolveUserId(jwt: Jwt): UUID = - userApi.getUserIdByAuthId(jwt.subject).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "No user found with authId: ${jwt.subject}") - } -} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt index 75d31255..34950e09 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt @@ -15,18 +15,10 @@ package com.sprintstart.sprintstartbackend.onboarding.external.enums enum class BoardCardKind( val placement: Placement, ) { - /** - * The moments between joining and a first accepted piece of work, and which have happened. - * - * Composed from contributions, not pull requests, so the wording the board carries is what - * names one unit of accepted work. - */ - PATH_TO_FIRST_CONTRIBUTION(Placement.BASELINE), - /** * What still has to be true before this hire can work: accounts, access, a machine that builds. * - * Baseline rather than mentor-placed for the same reason the path card is: nobody should depend + * Baseline rather than mentor-placed: nobody should depend * on a model noticing that somebody has been unable to clone the repository for a week. It is * ensured on every board read and is the one card that is *most* useful on day one, when the * board is otherwise thin. @@ -58,7 +50,7 @@ enum class BoardCardKind( * The task the hire is on, and where it came from. * * Not part of the baseline, because it is only true some of the time — somebody with no claimed - * goal and no Task 0 is not "between tasks", they simply have no task, and a card about nothing + * goal is not "between tasks", they simply have no task, and a card about nothing * is worse than no card. The mentor places it, and confirming `claim_goal` places it too. */ CURRENT_TASK(Placement.MENTOR), diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt index 89436357..e8442f69 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt @@ -13,7 +13,6 @@ enum class BuddyActionType( val label: String, ) { FLAG_TO_PM("flag_to_pm", "Flag this to your PM"), - CLAIM_TASK_ZERO("claim_task_zero", "Start Task 0"), OPEN_ORIENTATION("open_orientation", "Open the task packet"), CLAIM_GOAL("claim_goal", "Work toward this task"), REQUEST_ATTESTATION("request_attestation", "Ask them to confirm this"), diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/RampStage.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/RampStage.kt deleted file mode 100644 index abb9a286..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/RampStage.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.external.enums - -/** - * Where a hire is on the ramp of real tasks. - * - * Onboarding is a ramp of real work, not a course that ends in one — so the stages describe *what - * kind of task you are on*, not how much curriculum is left. There is no percentage here on - * purpose: "72% complete" answers a question nobody has, while "you have merged one change and are - * on your second" answers the one everybody has. - * - * Derived on read from facts that already exist (a Task 0 assignment, merged pull requests, a - * claimed goal). Nothing advances a hire but doing the work. - */ -enum class RampStage { - /** Mechanics. Proves the branch → PR → review → merge loop, and credits nothing. */ - TASK_ZERO, - - /** - * A real change in a familiar area, matched to competencies already held. The novelty is the - * codebase, not the technology. - */ - TASK_ONE, - - /** - * Unfamiliar area, or requiring judgement — deliberately past current placement. This is where - * the buddy and task orientation earn their keep. - */ - TASK_TWO_PLUS, - - /** - * A task completed with no buddy intervention and no review rework. - * - * Not "all nodes mastered": the exit condition is the honest operational definition of "can be - * left alone here", and it is directly measurable. - */ - AUTONOMOUS, -} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt index 2afb19cf..d1e606f1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt @@ -21,6 +21,6 @@ enum class Rigor { /** A named accountable person, never the hire, confirmed the work happened and met the bar. */ ATTESTED, - /** The hire said so, with nothing behind it. Never counts toward autonomy. */ + /** The hire said so, with nothing behind it. Never counts as evidence of the work. */ DECLARED, } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/RetiredBoardCardCleanup.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/RetiredBoardCardCleanup.kt new file mode 100644 index 00000000..f65aaed9 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/RetiredBoardCardCleanup.kt @@ -0,0 +1,38 @@ +package com.sprintstart.sprintstartbackend.onboarding.listener + +import com.sprintstart.sprintstartbackend.onboarding.repository.BoardCardRepository +import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +/** + * Deletes board cards whose kind was retired, once, when the application starts. + * + * A card row of a kind the enum no longer has cannot be read through the entity, so a single + * leftover would fail every board read for that hire. `ddl-auto: update` never removes rows, and + * the SQL migrations are not run automatically, so the delete happens here instead. Idempotent: once + * the rows are gone it deletes nothing. + */ +@Component +class RetiredBoardCardCleanup( + private val boardCardRepository: BoardCardRepository, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + /** + * Not transactional itself: the delete runs in the repository's own transaction, so a failure + * rolls back there and is caught here, rather than marking an outer transaction rollback-only + * and failing startup on commit. Housekeeping must never be what stops the application starting. + */ + @EventListener(ApplicationReadyEvent::class) + @Suppress("TooGenericExceptionCaught") + fun removeRetiredCards() { + try { + val removed = boardCardRepository.deleteRetiredKinds() + if (removed > 0) logger.info("Removed {} board cards of retired kinds", removed) + } catch (e: RuntimeException) { + logger.warn("Could not remove board cards of retired kinds; see V19__retire_legacy_onboarding.sql", e) + } + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListener.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListener.kt index 0e67e7b7..8c4ec1fe 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListener.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListener.kt @@ -2,7 +2,6 @@ package com.sprintstart.sprintstartbackend.onboarding.listener import com.sprintstart.sprintstartbackend.onboarding.repository.ArrivalStepStateRepository import com.sprintstart.sprintstartbackend.onboarding.repository.AttestationRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.AutonomyMilestoneRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardCardRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardStructureRepository @@ -10,7 +9,6 @@ import com.sprintstart.sprintstartbackend.onboarding.repository.BuddyMessageRepo import com.sprintstart.sprintstartbackend.onboarding.repository.BuddySessionRepository import com.sprintstart.sprintstartbackend.onboarding.repository.GithubHistoryPriorRepository import com.sprintstart.sprintstartbackend.onboarding.repository.KnowledgeRequestRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository import com.sprintstart.sprintstartbackend.onboarding.repository.UserCompetencyStateRepository import com.sprintstart.sprintstartbackend.onboarding.repository.UserGoalRepository import com.sprintstart.sprintstartbackend.user.external.events.UserDeletedEvent @@ -49,8 +47,6 @@ class UserDeletedListener( private val boardCardRepository: BoardCardRepository, private val boardStructureRepository: BoardStructureRepository, private val userGoalRepository: UserGoalRepository, - private val taskZeroAssignmentRepository: TaskZeroAssignmentRepository, - private val autonomyMilestoneRepository: AutonomyMilestoneRepository, private val attestationRepository: AttestationRepository, private val knowledgeRequestRepository: KnowledgeRequestRepository, private val githubHistoryPriorRepository: GithubHistoryPriorRepository, @@ -68,8 +64,6 @@ class UserDeletedListener( userCompetencyStateRepository.deleteAllByUserId(userId) arrivalStepStateRepository.deleteAllByUserId(userId) userGoalRepository.deleteAllByUserId(userId) - taskZeroAssignmentRepository.deleteAllByHireId(userId) - autonomyMilestoneRepository.deleteAllByHireId(userId) attestationRepository.deleteAllByHireId(userId) knowledgeRequestRepository.deleteAllByHireId(userId) githubHistoryPriorRepository.deleteAllByUserId(userId) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/AutonomyMilestone.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/AutonomyMilestone.kt deleted file mode 100644 index 01ae8058..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/AutonomyMilestone.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.model.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint -import java.time.Instant -import java.util.UUID - -/** - * When a hire first reached autonomy on a project, and on what. - * - * Almost everything about the ramp is derived on read — which stage, which task, what unlocked it — - * because every underlying fact already lives somewhere durable. This row exists because the - * moment itself is not derivable: recomputing "is autonomous now" gives a boolean, and a boolean - * cannot be announced. The end of onboarding should be a dated event somebody can point at, for the - * hire and for their PM, not a percentage that quietly crosses a line. - * - * Written once, on the first read where the condition holds, and never updated: if later work needs - * more help, that is ordinary — it does not un-happen the day somebody first shipped a change with - * no help and no rework. The same reasoning that keeps the competency ledger monotonic. - */ -@Entity -@Table( - name = "autonomy_milestones", - uniqueConstraints = [ - UniqueConstraint(name = "uq_autonomy_milestones_hire_project", columnNames = ["hire_id", "project_id"]), - ], -) -class AutonomyMilestone( - @Id - val id: UUID = UUID.randomUUID(), - @Column(name = "hire_id", nullable = false) - val hireId: UUID, - @Column(name = "project_id", nullable = false) - val projectId: UUID, - /** When the qualifying pull request merged — the real moment, not when we noticed. */ - @Column(name = "reached_at", nullable = false) - val reachedAt: Instant, - /** The pull request that proved it, so the claim can be checked rather than trusted. */ - @Column(name = "proven_by_artifact_id", nullable = true) - val provenByArtifactId: UUID? = null, -) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt index e086a29a..6def447e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt @@ -67,10 +67,13 @@ class StarterWorkTaskProposal( @Column(nullable = false) var reviewed: Boolean = false, /** - * Whether a PM has flagged this task as suitable for Task 0 — the trivial first - * task a new hire is auto-assigned once their environment is ready, to walk the - * branch → PR → review → merge loop once while the stakes are nil. A deliberate PM - * choice, not a default. + * Retired: Task 0, the first task a hire was handed automatically, is gone -- onboarding is the + * path their PM's blueprint prescribes (#311). Nothing reads or writes this any more. + * + * Still mapped only because databases created before then hold it as a NOT NULL column with no + * default, and `ddl-auto: update` never drops a column: without the field every new proposal + * would fail to insert. `V19__retire_legacy_onboarding.sql` gives the column a default; once that + * has run everywhere, delete this field and drop the column. */ @Column(name = "task_zero_eligible", nullable = false) var taskZeroEligible: Boolean = false, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/TaskZeroAssignment.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/TaskZeroAssignment.kt deleted file mode 100644 index 56993574..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/TaskZeroAssignment.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.model.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.Instant -import java.util.UUID - -/** - * The trivial first task a new hire was auto-assigned once their environment came up. - * - * Task 0 exists to prove the branch → PR → review → merge loop works end to end while the stakes - * are nil, put the hire's name in the history, and create the first real interaction with their - * reviewer — not to teach anything. Completing it credits nothing in the competency ledger; - * there is no [com.sprintstart.sprintstartbackend.onboarding.model.entity.UserCompetencyState] - * write anywhere in this flow. It proves the loop, not a competency. - * - * One per hire per project. Assignment is automatic on environment readiness and undoable — removing - * the row frees the task for someone else, because a Task 0 (a real typo or doc fix) is a single - * piece of wanted work, not a fabricated exercise that can be handed to two people at once. - */ -@Entity -@Table(name = "task_zero_assignments") -class TaskZeroAssignment( - @Id - val id: UUID = UUID.randomUUID(), - @Column(name = "hire_id", nullable = false) - val hireId: UUID, - @Column(name = "project_id", nullable = false) - val projectId: UUID, - /** The approved, Task-0-eligible starter-work proposal that was assigned. */ - @Column(name = "proposal_id", nullable = false) - val proposalId: UUID, - @Column(name = "assigned_at", nullable = false) - val assignedAt: Instant = Instant.now(), -) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt index 864169ef..cc9fd579 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt @@ -13,5 +13,4 @@ fun StarterWorkTaskProposal.toResponse(): StarterWorkTaskProposalResponse = sourceUrl = sourceUrl, competencyKeys = competencyKeys.toList(), status = status, - taskZeroEligible = taskZeroEligible, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt deleted file mode 100644 index 56fbe067..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.model.request.starterwork - -/** - * A PM's decision on whether a live starter-work task is suitable as a hire's Task 0. - */ -data class SetTaskZeroEligibilityRequest( - val eligible: Boolean, -) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/attestation/AttestationResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/attestation/AttestationResponse.kt index 828301e5..1db8d48d 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/attestation/AttestationResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/attestation/AttestationResponse.kt @@ -8,7 +8,7 @@ import java.util.UUID * One request for somebody to confirm a hire's work. * * [returnedCount] is shown rather than hidden: work that took three passes is not the same as work - * that took none, and autonomy reads exactly this number. + * that took none. */ data class AttestationResponse( val id: UUID, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt index 0d352c05..3cc03d74 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt @@ -53,10 +53,6 @@ data class BoardCardResponse( visible = true, ) @JsonSubTypes( - JsonSubTypes.Type( - value = PathToFirstContributionContent::class, - name = "PATH_TO_FIRST_CONTRIBUTION", - ), JsonSubTypes.Type(value = ArrivalStepsContent::class, name = "ARRIVAL_STEPS"), JsonSubTypes.Type(value = OpenPullRequestsContent::class, name = "OPEN_PULL_REQUESTS"), JsonSubTypes.Type(value = CurrentTaskContent::class, name = "CURRENT_TASK"), @@ -72,24 +68,6 @@ sealed interface BoardCardContent { val kind: BoardCardKind } -/** - * The moments between joining and a first accepted piece of work. - * - * Composed from the hire's contribution timeline, so it holds for every track. - * Every timestamp is nullable: "has not happened yet" is the normal state mid-onboarding, and it - * is not the same as zero. - */ -data class PathToFirstContributionContent( - override val kind: BoardCardKind = BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, - val moments: List, - /** How much accepted work there is so far — the ramp's only real counter. */ - val acceptedCount: Int, - /** When onboarding ended for this hire, dated. Null while it is still going. */ - val autonomyReachedAt: Instant?, - /** Why this hire currently reads as stalled, in plain words, or null when they do not. */ - val stalledReason: String?, -) : BoardCardContent - /** * What still has to be true before this hire can work, and what they have already settled. * @@ -104,26 +82,6 @@ data class ArrivalStepsContent( val outstandingCount: Int, ) : BoardCardContent -/** - * One moment on the path, and whether it has happened. - * - * [key] is a stable identifier the client maps to its own copy. [reachedAt] null means not yet; - * the client renders it as a dash, not a zero. - */ -data class BoardMomentResponse( - val key: BoardMomentKey, - val reachedAt: Instant?, -) - -/** The moments a path card reports, in the order they normally happen. */ -enum class BoardMomentKey { - JOINED, - TASK_CLAIMED, - WORK_SUBMITTED, - FIRST_RESPONSE, - WORK_ACCEPTED, -} - /** * The hire's still-open pull requests, longest-waiting first. * @@ -165,8 +123,6 @@ data class CurrentTaskContent( val title: String?, val summary: String?, val url: String?, - /** True when the hire claimed this as their goal, false for a Task 0 they were handed. */ - val chosen: Boolean, /** * True once the issue behind this task is closed where it lives. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt index 726d5904..79339579 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt @@ -3,8 +3,8 @@ package com.sprintstart.sprintstartbackend.onboarding.model.response.buddy /** * The outcome of a confirmed buddy action, as a single line the buddy can relay in the thread. * - * [ok] is true when the action changed something, false when it legibly could not (e.g. no eligible - * Task 0). A false outcome is a handled state, not an error — [message] always carries a reason the + * [ok] is true when the action changed something, false when it legibly could not (e.g. no current + * task to open a packet for). A false outcome is a handled state, not an error — [message] always carries a reason the * hire can read either way. */ data class BuddyActionResponse( diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/metrics/OnboardingMetricsResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/metrics/OnboardingMetricsResponse.kt index ca0d7fd7..241fa834 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/metrics/OnboardingMetricsResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/metrics/OnboardingMetricsResponse.kt @@ -4,10 +4,10 @@ import java.time.Instant import java.util.UUID /** - * One hire's onboarding, as a sequence of moments and the gaps between them. + * One hire's way to their first accepted work, as a sequence of moments and the gaps between them. * * Every timestamp is nullable and every gap is nullable, because "has not happened yet" is the - * normal state of a hire mid-onboarding and is a different thing from zero. A dashboard that + * normal state of a hire early on and is a different thing from zero. A dashboard that * renders an unreached milestone as `0 days` reports success where there is none. * * The fields say "contribution", never "pull request". They are composed from @@ -23,12 +23,6 @@ data class HireTimelineResponse( val githubLogin: String?, /** Null for assignments made before joining was recorded — "clock unknown", not "joined now". */ val joinedAt: Instant?, - /** - * When the hire was auto-assigned their Task 0 — the trivial first task that proves the loop. - * Distinct from [firstTaskClaimedAt], which is a goal the hire chose; this one is handed to them - * on their first read. Null when none has been assigned. - */ - val taskZeroAssignedAt: Instant?, val firstTaskClaimedAt: Instant?, /** When they first put work up for somebody else to look at, whatever kind of work it is. */ val firstContributionOpenedAt: Instant?, @@ -52,14 +46,6 @@ data class HireTimelineResponse( val stalled: Boolean, /** What the stall is attributed to, in plain words; null when not stalled. */ val stalledReason: String?, - /** - * When this hire reached autonomy — a task completed with no buddy intervention and no review - * rework. Null while onboarding is still going. - * - * The end of onboarding is a dated event rather than a threshold crossed, so a PM sees *when* - * somebody became independent rather than a percentage that happened to reach 100. - */ - val autonomyReachedAt: Instant?, /** * How much of this hire's work was sent back for changes. * @@ -71,7 +57,7 @@ data class HireTimelineResponse( ) /** - * A project's onboarding health. + * How a project's people are getting their work in. * * Medians rather than means throughout: one hire who took four months to their first accepted piece * of work should not be able to make the cohort look slow, and one who finished on day one should diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/ramp/RampResponses.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/ramp/RampResponses.kt deleted file mode 100644 index 5d9228db..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/ramp/RampResponses.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.model.response.ramp - -import com.sprintstart.sprintstartbackend.onboarding.external.enums.RampStage -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.StarterWorkTaskProposalResponse -import java.time.Instant -import java.util.UUID - -/** - * Whether a hire has been shown to work unsupervised here, and if not, what is missing. - * - * [blockers] is the honest half. "Not autonomous yet" without a reason is a grade; with one it is a - * next step. Empty when [reachedAt] is set. - */ -data class AutonomyResponse( - val reached: Boolean, - /** When it happened — the qualifying merge, not when the system noticed. */ - val reachedAt: Instant?, - val provenByArtifactId: UUID?, - val blockers: List, -) - -/** - * A hire's position on the ramp of real tasks, on one project. - * - * There is deliberately no completion percentage: the ramp is real work, and a percentage of - * real work is a number nobody can act on. [stage] and [currentTask] answer what somebody is - * actually doing; [unlockedBy] answers why they are there. - */ -data class MyRampResponse( - val stage: RampStage, - val currentTask: StarterWorkTaskProposalResponse?, - /** One line saying what moved the hire to this stage — never a score. */ - val unlockedBy: String, - /** Pull requests this hire has merged on the project. The ramp's only real counter. */ - val mergedCount: Int, - /** Competency keys credited by merged work, not by chat placement. */ - val creditedCompetencyKeys: List, - val autonomy: AutonomyResponse, -) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/MyTaskZeroResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/MyTaskZeroResponse.kt deleted file mode 100644 index c86a18b6..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/MyTaskZeroResponse.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork - -import java.time.Instant - -/** - * A hire's Task 0 on one project, as they see it on their first-week surface. - * - * Available from day one — there is no environment-readiness precondition. Every combination is a - * real, handled state, none an error: - * - a [task] → the auto-assigned first task, with the PR loop to walk; - * - [noneAvailable] with no task → no PM has flagged a Task 0 yet. A hire whose first day would - * otherwise end with "pick something" instead sees "nothing to assign right now", which is a gap - * for the PM to fill, not a failure of the hire. - * - * [loopProven] is derived — true once the hire has merged any pull request — and writes nothing to - * the competency ledger: Task 0 proves the loop, not a competency. - */ -data class MyTaskZeroResponse( - val task: StarterWorkTaskProposalResponse?, - val assignedAt: Instant?, - val noneAvailable: Boolean, - val loopProven: Boolean, -) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt index 5c5fa253..d203540d 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt @@ -15,8 +15,6 @@ data class StarterWorkTaskProposalResponse( val sourceUrl: String?, val competencyKeys: List, val status: ProposalStatus, - /** True when a PM has flagged this approved task as suitable for Task 0. */ - val taskZeroEligible: Boolean, /** Which track this work is for, or null when it suits any role. */ ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/AutonomyMilestoneRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/AutonomyMilestoneRepository.kt deleted file mode 100644 index 322649cc..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/AutonomyMilestoneRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.repository - -import com.sprintstart.sprintstartbackend.onboarding.model.entity.AutonomyMilestone -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.stereotype.Repository -import java.util.UUID - -@Repository -interface AutonomyMilestoneRepository : JpaRepository { - fun findByHireIdAndProjectId(hireId: UUID, projectId: UUID): AutonomyMilestone? - - fun findAllByProjectId(projectId: UUID): List - - fun deleteAllByHireId(hireId: UUID) -} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/BoardCardRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/BoardCardRepository.kt index 1c4514b0..60b50e9f 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/BoardCardRepository.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/BoardCardRepository.kt @@ -2,6 +2,9 @@ package com.sprintstart.sprintstartbackend.onboarding.repository import com.sprintstart.sprintstartbackend.onboarding.model.entity.BoardCard import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.transaction.annotation.Transactional import java.util.UUID interface BoardCardRepository : JpaRepository { @@ -14,4 +17,22 @@ interface BoardCardRepository : JpaRepository { fun findAllByBoardId(boardId: UUID): List fun deleteAllByBoardId(boardId: UUID) + + /** + * Removes the cards of kinds the catalog no longer has. + * + * Native, because these rows cannot be loaded at all: their `kind` is not a [BoardCardKind] any + * more, so any read through the entity would fail on them. `PATH_TO_FIRST_CONTRIBUTION` was the + * joined -> first-accepted-work card, retired when onboarding became the blueprint path (#311). + * + * Compared as text: where the column is a database enum (H2) the old value is no longer one of + * its members, and comparing the enum with it directly is an error rather than no match. + */ + @Modifying + @Transactional + @Query( + "DELETE FROM board_cards WHERE CAST(kind AS VARCHAR(64)) IN ('PATH_TO_FIRST_CONTRIBUTION')", + nativeQuery = true, + ) + fun deleteRetiredKinds(): Int } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/StarterWorkTaskProposalRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/StarterWorkTaskProposalRepository.kt index 9b4dc325..8947baca 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/StarterWorkTaskProposalRepository.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/StarterWorkTaskProposalRepository.kt @@ -26,9 +26,6 @@ interface StarterWorkTaskProposalRepository : JpaRepository - /** * Records that reconciliation looked at these rows and found nothing to change. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/TaskZeroAssignmentRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/TaskZeroAssignmentRepository.kt deleted file mode 100644 index a8f495d8..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/repository/TaskZeroAssignmentRepository.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.repository - -import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskZeroAssignment -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.stereotype.Repository -import java.util.UUID - -@Repository -interface TaskZeroAssignmentRepository : JpaRepository { - fun findByHireIdAndProjectId(hireId: UUID, projectId: UUID): TaskZeroAssignment? - - fun deleteByHireIdAndProjectId(hireId: UUID, projectId: UUID) - - /** Proposal ids already handed to a hire — so the same piece of work is never assigned twice. */ - @Query("SELECT a.proposalId FROM TaskZeroAssignment a") - fun findAllAssignedProposalIds(): List - - fun deleteAllByHireId(hireId: UUID) -} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AttestationService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AttestationService.kt index 0ef489b9..404684a2 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AttestationService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AttestationService.kt @@ -121,7 +121,7 @@ class AttestationService( * * Stays [AttestationState.REQUESTED] rather than moving to a rejected state: the hire is * expected to act on the reason and the same request carries on, exactly as a pull request with - * changes requested does. [Attestation.returnedCount] is what autonomy later reads. + * changes requested does. [Attestation.returnedCount] is what the metrics count as rework. * * @throws ResponseStatusException 400 when no reason is given — "no, and I won't say why" is * not something a hire can act on. diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt index 79f7c677..57427d6d 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt @@ -23,8 +23,6 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.Arriva import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardCardContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardCardResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardCompetencyResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentKey -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardPullRequestResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardSuggestedTaskResponse @@ -36,10 +34,8 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.LinkCo import com.sprintstart.sprintstartbackend.onboarding.model.response.board.MemoryRecapContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.NoteContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.OpenPullRequestsContent -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathToFirstContributionContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.SuggestedTasksContent import com.sprintstart.sprintstartbackend.onboarding.model.response.competency.MyCompetencyResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.metrics.HireTimelineResponse import com.sprintstart.sprintstartbackend.onboarding.repository.BoardCardRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardDiagramRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardRepository @@ -70,7 +66,6 @@ class BoardService( private val boardRepository: BoardRepository, private val boardCardRepository: BoardCardRepository, private val projectMembershipApi: ProjectMembershipApi, - private val onboardingMetricsService: OnboardingMetricsService, private val openPullRequestReader: OpenPullRequestReader, private val currentTaskReader: CurrentTaskReader, private val starterWorkTaskProposalService: StarterWorkTaskProposalService, @@ -117,7 +112,6 @@ class BoardService( // Whether the hire is on a task at all, for the pin. Read through the same // [CurrentTaskReader] the card's content comes from, so the pin and the card agree. val onATask = currentTaskReader.currentTaskFor(userId, projectId) != null - val timeline = onboardingMetricsService.getHireTimeline(userId, projectId) // One query for every diagram on the board, and the stored picture rather than a fresh one: // assembling costs a model call. The client revalidates afterwards. val diagrams = boardDiagramRepository @@ -130,7 +124,7 @@ class BoardService( cards = cards .filter { it.state == BoardCardState.ACTIVE } .sortedWith(attentionOrder(arrivalSteps, onATask)) - .map { it.toResponse(member, projectId, timeline, diagrams[it.id], arrivalSteps) }, + .map { it.toResponse(member, projectId, diagrams[it.id], arrivalSteps) }, ) } @@ -288,7 +282,7 @@ class BoardService( ), ) val arrivalSteps = arrivalStepService.forHire(member.userId) - return card.toResponse(member, projectId, timeline = null, arrivalSteps = arrivalSteps) + return card.toResponse(member, projectId, arrivalSteps = arrivalSteps) } /** @@ -311,7 +305,7 @@ class BoardService( val member = memberOrNull(userId, board.projectId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "You are not a member of that project") val arrivalSteps = arrivalStepService.forHire(member.userId) - return card.toResponse(member, board.projectId, timeline = null, arrivalSteps = arrivalSteps) + return card.toResponse(member, board.projectId, arrivalSteps = arrivalSteps) } /** @@ -386,11 +380,9 @@ class BoardService( card: BoardCard, member: ProjectMember, projectId: UUID, - timeline: HireTimelineResponse?, diagram: BoardDiagram?, arrivalSteps: List, ): BoardCardContent = when (card.kind) { - BoardCardKind.PATH_TO_FIRST_CONTRIBUTION -> pathContent(member, timeline) BoardCardKind.ARRIVAL_STEPS -> arrivalStepsContent(arrivalSteps) BoardCardKind.OPEN_PULL_REQUESTS -> openPullRequestsContent(member, projectId) BoardCardKind.CURRENT_TASK -> currentTaskContent(member.userId, projectId) @@ -489,8 +481,8 @@ class BoardService( /** * The task the hire is on, read — never assigned. * - * Read through [CurrentTaskReader], not `TaskZeroService.getForHire`, which assigns on - * read. Hydration runs on every page load, so it must not be able to hand out a task. + * Read through [CurrentTaskReader], the same read the task packet uses, so the card and the + * packet cannot be about different tasks. * * A card with no task on it is a real state and says so. */ @@ -501,8 +493,6 @@ class BoardService( title = task?.title, summary = task?.summary, url = task?.sourceUrl, - // True for a goal the hire claimed, false for a Task 0 they were handed. - chosen = task != null && currentTaskReader.isClaimedGoal(userId, projectId), // Reconciliation moves a proposal to STALE when its issue closes at the source, so the // card can say so without a lookup of its own. closedAtSource = task?.status == ProposalStatus.STALE, @@ -529,34 +519,6 @@ class BoardService( }, ) - /** - * The path card's content, from the same timeline the PM dashboard reads. - * - * A hire with no timeline at all still gets the card, with every moment unreached: "nothing has - * happened yet" is the honest day-one state and is exactly what somebody on day one should see, - * rather than a card that is missing until they have already made progress. - */ - private fun pathContent( - member: ProjectMember, - timeline: HireTimelineResponse?, - ): PathToFirstContributionContent = PathToFirstContributionContent( - moments = listOf( - // Joined comes from the membership rather than the timeline, so it is still shown when - // there is no timeline to read. - BoardMomentResponse(BoardMomentKey.JOINED, member.joinedAt), - BoardMomentResponse(BoardMomentKey.TASK_CLAIMED, timeline?.firstTaskClaimedAt), - // The timeline's field names still say "pull request"; the values behind them are - // composed from contributions of any kind, which is why the card can name them - // generally. - BoardMomentResponse(BoardMomentKey.WORK_SUBMITTED, timeline?.firstContributionOpenedAt), - BoardMomentResponse(BoardMomentKey.FIRST_RESPONSE, timeline?.firstResponseAt), - BoardMomentResponse(BoardMomentKey.WORK_ACCEPTED, timeline?.firstContributionAcceptedAt), - ), - acceptedCount = timeline?.acceptedContributionCount ?: 0, - autonomyReachedAt = timeline?.autonomyReachedAt, - stalledReason = timeline?.stalledReason, - ) - private fun openPullRequestsContent( member: ProjectMember, projectId: UUID, @@ -580,7 +542,6 @@ class BoardService( private fun BoardCard.toResponse( member: ProjectMember, projectId: UUID, - timeline: HireTimelineResponse?, diagram: BoardDiagram? = null, arrivalSteps: List = emptyList(), ) = BoardCardResponse( @@ -589,7 +550,7 @@ class BoardService( owner = owner, position = position, placedAt = placedAt, - content = hydrate(this, member, projectId, timeline, diagram, arrivalSteps), + content = hydrate(this, member, projectId, diagram, arrivalSteps), ) /** diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index abf6fb6e..a4e939a1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -40,7 +40,6 @@ import java.util.UUID @Service @Suppress("TooManyFunctions") // Nine wrapped actions, each with a propose + a perform helper. class BuddyActionService( - private val taskZeroService: TaskZeroService, private val taskOrientationService: TaskOrientationService, private val knowledgeBaseService: KnowledgeBaseService, private val userGoalService: UserGoalService, @@ -60,7 +59,6 @@ class BuddyActionService( fun actionSpecs(userId: UUID): List = listOf( FLAG_TO_PM_SPEC, - CLAIM_TASK_ZERO_SPEC, OPEN_ORIENTATION_SPEC, CLAIM_GOAL_SPEC, REQUEST_ATTESTATION_SPEC, @@ -302,7 +300,7 @@ class BuddyActionService( /** * Runs a confirmed action on behalf of [jwt]'s user, scoped to their re-resolved project. * - * Never throws for a handled outcome: an expected precondition failure ("no eligible Task 0", + * Never throws for a handled outcome: an expected precondition failure ("no current task", * "not a member") comes back as `ok = false` with a legible message, so the buddy always has a * line to relay. Only a genuinely unexpected failure propagates. Blocking work runs on the IO * dispatcher; opening orientation is itself suspend and manages its own transactions. @@ -375,7 +373,6 @@ class BuddyActionService( BuddyActionType.OPEN_ORIENTATION -> openOrientation(resolved.userId, resolved.projectId) else -> withContext(Dispatchers.IO) { when (type) { - BuddyActionType.CLAIM_TASK_ZERO -> claimTaskZero(resolved.userId, resolved.projectId) BuddyActionType.FLAG_TO_PM -> flagToPm(authId, resolved.projectId, request.question) BuddyActionType.CLAIM_GOAL -> claimGoal(resolved.userId, authId, resolved.projectId, request.taskId) @@ -396,22 +393,6 @@ class BuddyActionService( } } - private fun claimTaskZero(userId: UUID, projectId: UUID): BuddyActionResponse { - val result = taskZeroService.getForHire(userId, projectId) - val task = result.task - return if (task != null) { - BuddyActionResponse( - ok = true, - message = "Task 0 is yours: “${task.title}”. Open the task packet when you're ready to start.", - ) - } else { - BuddyActionResponse( - ok = false, - message = "There's no eligible Task 0 to start yet — your PM marks a starter task as Task 0.", - ) - } - } - private suspend fun openOrientation(userId: UUID, projectId: UUID): BuddyActionResponse { val orientation = taskOrientationService.getForHire(userId, projectId) return if (orientation.packet != null) { @@ -475,7 +456,7 @@ class BuddyActionService( BuddyActionResponse( ok = true, message = "Asked them to confirm “${attestation.title}”. " + - "It counts once they do — you will see it on your ramp.", + "It counts once they do — you will see it in what you have shown.", ) } catch (e: ResponseStatusException) { // A handled precondition ("not on this project", "that is you") is a sentence the buddy @@ -555,11 +536,10 @@ class BuddyActionService( private fun BuddyToolCallDto.uuidArg(name: String): UUID? = runCatching { UUID.fromString(stringArg(name)) }.getOrNull() - /** A verb phrase for the reason lines, e.g. "start Task 0", "flag this to a PM". */ + /** A verb phrase for the reason lines, e.g. "claim a goal", "flag this to a PM". */ private fun BuddyActionType.gerund(): String = when (this) { BuddyActionType.FLAG_TO_PM -> "flag this to a PM" - BuddyActionType.CLAIM_TASK_ZERO -> "start Task 0" BuddyActionType.OPEN_ORIENTATION -> "open a task packet" BuddyActionType.CLAIM_GOAL -> "claim a goal" BuddyActionType.REQUEST_ATTESTATION -> "ask somebody to confirm your work" @@ -665,17 +645,9 @@ class BuddyActionService( }, ) - val CLAIM_TASK_ZERO_SPEC = BuddyToolSpecDto( - name = BuddyActionType.CLAIM_TASK_ZERO.toolName, - description = "Offer to start the hire's Task 0 — their first assigned starter task. This does NOT " + - "assign anything by itself; it shows the hire a confirm button and runs only if they click. Use " + - "when the hire is ready to begin their first piece of real work. Takes no arguments.", - parameters = noArgs(), - ) - val OPEN_ORIENTATION_SPEC = BuddyToolSpecDto( name = BuddyActionType.OPEN_ORIENTATION.toolName, - description = "Offer to assemble the task orientation packet for the hire's current task — a " + + description = "Offer to assemble the task orientation packet for the task the hire claimed — a " + "step-by-step, cited guide to setting up, finding the code, making the change, and opening the " + "PR. Proposes only; the hire confirms. Use when they ask how to start the task they have. Takes " + "no arguments.", diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt index 52928e3b..ca9a3cc2 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt @@ -18,7 +18,7 @@ import java.util.UUID * [BuddyToolExecutor.toolSpecs] applies. Deriving rather than listing is what makes the two * incapable of disagreeing, and a chip is louder than a tool because the hire sees it. * - * Order follows the spec list, so arrival comes first. + * Order follows the spec list, so the onboarding path comes first. * * No action tools. `claim_goal`, `request_attestation` and the rest are proposed by the * mentor and confirmed by the hire; a chip naming one would read as a button that does it. A chip @@ -62,10 +62,6 @@ class BuddySuggestionService( * question it writes would be putting words in the hire's mouth. */ val CATALOG: Map = mapOf( - BuddyToolExecutor.GET_ARRIVAL_STEPS to BuddySuggestionResponse( - label = "What do I still need?", - question = "What do I still need to get set up?", - ), // Mounted only for a hire who has an onboarding path, so the chip can talk about one // without checking. Asks where they are rather than for the plan: the mentor answers // with the phase they are standing in and one next thing, which is what a hire looking @@ -74,6 +70,10 @@ class BuddySuggestionService( label = "Where am I on my path?", question = "Where am I in my onboarding path, and what should I do next?", ), + BuddyToolExecutor.GET_ARRIVAL_STEPS to BuddySuggestionResponse( + label = "What do I still need?", + question = "What do I still need to get set up?", + ), BuddyToolExecutor.GET_SUGGESTED_TASKS to BuddySuggestionResponse( label = "What should I work on?", question = "What should I work on next?", @@ -82,9 +82,12 @@ class BuddySuggestionService( label = "Is my PR stuck?", question = "Are any of my pull requests stuck waiting on a review?", ), + // About their work, not their onboarding: the metrics are contributions and reviews, and a + // chip asking how onboarding is going would send that question here instead of to the + // path, which is the one that can answer it. BuddyToolExecutor.GET_MY_METRICS to BuddySuggestionResponse( - label = "How am I doing?", - question = "How is my onboarding going so far?", + label = "How's my work going?", + question = "How is my work going so far — is anything waiting on someone?", ), // Not "Where do I stand?" any more. Next to the path chip that reads "Where am I on my // path?" the two were a coin toss, and they lead to different halves of the product: this diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt index 09642709..2877ce30 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt @@ -21,9 +21,9 @@ import java.util.UUID * Runs the backend-owned tools the buddy agent may call, and describes them to the AI reasoner. * * The buddy answers corpus questions AI-side (``search_docs``); tools here answer questions about - * the hire's *own* onboarding, which only the backend can see. Each tool is executed strictly on - * behalf of the resolved caller — the agent never supplies whose data to read, so one hire can - * never read another's metrics through the buddy. + * the hire's *own* state -- their onboarding path, their setup, their work -- which only the backend + * can see. Each tool is executed strictly on behalf of the resolved caller — the agent never + * supplies whose data to read, so one hire can never read another's metrics through the buddy. * * One function per buddy tool (plus the shared state snapshot the opener grounds itself in); the * count tracks how much the buddy can read about the hire, not a class doing unrelated things, @@ -56,16 +56,16 @@ class BuddyToolExecutor( * decided by what is actually there for them. */ fun toolSpecs(userId: UUID): List = buildList { - // First: what has to be true before somebody can work comes before how their work is - // going. Mounted only when a step applies -- "absent, never empty", read from the same - // service the board card uses so the two cannot disagree. + // First: the path *is* the onboarding, and the buddy is the tutor along it. A mentor that has + // not read it answers "what should I do next" out of the work pool while the hire is looking + // at a page that says something else. Mounted only when a path exists. + addAll(buddyPathTools.toolSpecs(userId)) + // Setup is not part of the onboarding -- an account or an access grant is something to + // chase whatever phase somebody is in. Mounted only when a step applies -- "absent, never + // empty", read from the same service the board card uses so the two cannot disagree. if (arrivalStepService.forHire(userId).isNotEmpty()) { add(GET_ARRIVAL_STEPS_SPEC) } - // Second, and ahead of everything about how their work is going: the path is the *plan*, so - // a mentor that has not read it answers "what should I do next" out of the work pool while - // the hire is looking at a page that says something else. Mounted only when a path exists. - addAll(buddyPathTools.toolSpecs(userId)) add(GET_MY_METRICS_SPEC) add(GET_MY_COMPETENCIES_SPEC) // Mounted only while something is still unplaced, on the same "absent, never empty" rule @@ -89,23 +89,23 @@ class BuddyToolExecutor( } /** - * A plain-text snapshot of the hire's own onboarding, for the buddy's opening greeting to - * ground itself in. Reuses the exact reads the caller-scoped tools expose, so the opener and - * the tools can never describe different states. + * A plain-text snapshot of the hire's own state, for the buddy's opening greeting to ground + * itself in. Reuses the exact reads the caller-scoped tools expose, so the opener and the tools + * can never describe different states. * - * Arrival comes first, and the order is the feature — a greeting grounded in progress - * before setup greets a hire who cannot clone the repository with a good first issue. Omitted - * entirely when no step applies: a greeting grounded in "arrival: nothing" will find something - * to say about it. + * The path comes first, and the order is the feature: the onboarding is what a greeting opens + * on. Setup comes next, ahead of how their work is going -- a greeting grounded in progress + * before setup greets a hire who cannot get in with a good first issue. Each part is omitted + * entirely when it has nothing: a greeting grounded in "arrival: nothing" will find something to + * say about it. */ fun stateSnapshot(userId: UUID): String = listOfNotNull( + // Absent entirely for a hire with no path, so a greeting can never open by discussing + // one they have not got. + buddyPathTools.snapshotFor(userId), ("Before they can work:\n" + getArrivalSteps(userId)) .takeIf { arrivalStepService.forHire(userId).isNotEmpty() }, - // Before progress, for the same reason the tool is mounted before the metrics one: the - // plan is what a greeting should open on. Absent entirely for a hire with no path, so a - // greeting can never open by discussing one they have not generated. - buddyPathTools.snapshotFor(userId), "Progress:\n" + getMyMetrics(userId), // Omitted for somebody whose work cannot be found at all, on the same rule as the // tool: a greeting handed "Open pull requests: you have not set a GitHub username" @@ -250,7 +250,7 @@ class BuddyToolExecutor( ?.projects .orEmpty() if (projects.isEmpty()) { - return "You are not a member of any project yet, so there are no onboarding metrics." + return "You are not a member of any project yet, so there are no work metrics." } val described = projects.mapNotNull { project -> onboardingMetricsService @@ -258,7 +258,7 @@ class BuddyToolExecutor( ?.let { describe(project.name, it) } } return described - .ifEmpty { listOf("No onboarding metrics are available for you yet.") } + .ifEmpty { listOf("No work metrics are available for you yet.") } .joinToString("\n\n") } @@ -279,7 +279,6 @@ class BuddyToolExecutor( } appendLine("- Stalled: $stall") appendLine("- Pull requests sent back for changes: ${timeline.returnedContributionCount}") - timeline.autonomyReachedAt?.let { appendLine("- Reached autonomy at: $it") } }.trim() /** @@ -476,11 +475,11 @@ class BuddyToolExecutor( val GET_MY_METRICS_SPEC = BuddyToolSpecDto( name = GET_MY_METRICS, - description = "The hire's own onboarding metrics on the project(s) they are onboarding " + - "on: open and merged pull requests, how long a pull request has been waiting on a " + - "review, whether they are stalled, review rework, and whether they have reached " + - "autonomy. Use this for questions about the hire's own progress, e.g. 'is my PR " + - "stuck?' or 'am I on track?'. Takes no arguments — it always reads the caller.", + description = "How the hire's own work is going on their project(s): open and merged pull " + + "requests, how long a pull request has been waiting on a review, whether their work " + + "is stalled, and review rework. Use this for 'is my PR stuck?' or 'how is my work " + + "going?'. It says nothing about their onboarding -- that is their path. Takes no " + + "arguments — it always reads the caller.", parameters = noArgs(), ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CompetencyPlacementService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CompetencyPlacementService.kt index 2079b751..00ec5d9a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CompetencyPlacementService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CompetencyPlacementService.kt @@ -26,8 +26,7 @@ import java.util.UUID * A weak prior, and the code says so rather than the prose. Every entry written here is * [CompetencySource.ASSESSED], and a row already carrying [CompetencySource.VERIFIED] is left * untouched: accepted work outranks anything somebody said about themselves, never the other way - * round. That is the same rule [RampService] states from the other side, enforced here so a - * conversation cannot walk a proven competency back. + * round. Enforced here so a conversation cannot walk a proven competency back. * * Otherwise the write is the monotonic find-or-create every ledger writer uses — a placement can * raise a level, never lower one. A hire who undersells themselves in chat loses nothing they had diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt index f7e84d2e..43c239e7 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt @@ -36,7 +36,7 @@ class ContributionService( * requests, the user id for attestations. * * Every provider runs, for every hire. Filtering the stream by the kind of work somebody is - * expected to do would take a PM's pull requests off their own ramp. + * expected to do would take a PM's pull requests off their own timeline. * * @param member The hire, already resolved against the project. * @param projectId The project to look in. @@ -57,9 +57,9 @@ class ContributionService( * the *goal* a hire is working toward, this is the *evidence* that they completed something. The * two meet only in that finishing the former produces the latter. * - * [firstResponseAt] null means nobody has answered yet — a finding, not missing data. A - * [returnedCount] of zero is half the operational definition of autonomy: acceptance alone cannot - * tell clean work from work sent back three times. + * [firstResponseAt] null means nobody has answered yet — a finding, not missing data. + * [returnedCount] is kept beside acceptance because acceptance alone cannot tell clean work from + * work sent back three times. * * Invariant: [state] `== ACCEPTED` implies [acceptedAt] is non-null, and vice versa. It is * established in the mappers that build these, which are the only way one is constructed. diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CurrentTaskReader.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CurrentTaskReader.kt index 5a9b9f08..8b57e89a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CurrentTaskReader.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/CurrentTaskReader.kt @@ -2,45 +2,30 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTaskProposal import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository import com.sprintstart.sprintstartbackend.onboarding.repository.UserGoalRepository import org.springframework.stereotype.Component import java.util.UUID /** - * The task a hire is on: their claimed goal, or their assigned Task 0 before they have one. + * The task a hire is on: the starter-work task they claimed as their goal. * - * Extracted for the same reason [OpenPullRequestReader] was: the ramp reads it and the board's - * current-task card reads it, and "which task is this person on" is not a question two callers - * should be able to answer differently. A hire told one thing on their board and another on their - * ramp has no way to know which is true. + * Extracted for the same reason [OpenPullRequestReader] was: the board's current-task card and the + * task packet both read it, and "which task is this person on" is not a question two callers + * should be able to answer differently. * - * Read-only, always. Unlike `TaskZeroService.getForHire`, this never assigns. Seeing where you - * are must never be what hands you your first task — which matters more here than it did in the - * ramp, because a board card is hydrated on every page load. + * Read-only, always, and only ever what the hire chose. Nothing hands a hire a task: their + * onboarding is the path their PM's blueprint prescribes, and claiming work is something they do + * alongside it. */ @Component class CurrentTaskReader( private val userGoalRepository: UserGoalRepository, - private val taskZeroAssignmentRepository: TaskZeroAssignmentRepository, private val starterWorkTaskProposalRepository: StarterWorkTaskProposalRepository, ) { - /** - * The task [hireId] is on for [projectId], or null when they are on none. - * - * A claimed goal outranks an assigned Task 0: Task 0 is what somebody is handed, a goal is what - * they chose, and once they have chosen the choice is the answer. - */ + /** The task [hireId] claimed on [projectId], or null when they have claimed none. */ fun currentTaskFor(hireId: UUID, projectId: UUID): StarterWorkTaskProposal? = userGoalRepository .findByUserIdAndProjectId(hireId, projectId) ?.sourceProposalId ?.let { starterWorkTaskProposalRepository.findById(it).orElse(null) } - ?: taskZeroAssignmentRepository - .findByHireIdAndProjectId(hireId, projectId) - ?.let { starterWorkTaskProposalRepository.findById(it.proposalId).orElse(null) } - - /** Whether the current task was chosen by the hire (a claimed goal) rather than handed to them. */ - fun isClaimedGoal(hireId: UUID, projectId: UUID): Boolean = - userGoalRepository.findByUserIdAndProjectId(hireId, projectId)?.sourceProposalId != null } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsService.kt index a9b6861b..c487aa11 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsService.kt @@ -14,22 +14,21 @@ import java.time.Instant import java.util.UUID /** - * How long onboarding is actually taking, derived from what the system already records. + * How a project's people get their work in: from joining to a first accepted contribution, and + * where it waits on somebody. * - * Derived on read, never emitted. There is no onboarding event table: every fact here - * already exists somewhere durable, and a second log would be a version of the same truth that - * drifts. + * Not onboarding progress. Onboarding is the path a PM's blueprint prescribes, and how far a hire + * is along it is read from that path. These are the contribution numbers beside it -- a hire can + * finish their path without having shipped anything, and ship long before they finish. * - * Nothing here reports a percentage of anything completed. The measure is - * time-to-first-accepted-contribution and time-to-autonomy. + * Derived on read, never emitted. Every fact here already exists somewhere durable, and a second log + * would be a version of the same truth that drifts. */ @Service class OnboardingMetricsService( private val projectMembershipApi: ProjectMembershipApi, private val contributionService: ContributionService, private val userGoalRepository: UserGoalRepository, - private val taskZeroService: TaskZeroService, - private val rampService: RampService, private val clock: Clock = Clock.systemUTC(), ) { /** @@ -103,7 +102,6 @@ class OnboardingMetricsService( displayName = member.displayName, githubLogin = login, joinedAt = member.joinedAt, - taskZeroAssignedAt = taskZeroService.assignedAtFor(member.userId, projectId), firstTaskClaimedAt = goalClaimedAt, firstContributionOpenedAt = opened, firstResponseAt = firstContribution?.firstResponseAt, @@ -117,9 +115,6 @@ class OnboardingMetricsService( longestOpenWaitHours = longestOpenWait, stalled = stalledReason != null, stalledReason = stalledReason, - // The end of onboarding belongs next to the other numbers about how onboarding is - // going. Read-only here: a PM opening the dashboard must never be what grants it. - autonomyReachedAt = rampService.autonomyReachedAtFor(member.userId, projectId), // R7's own measure, on our data: whether a suggested task was claimed, and whether it // came back sent-for-rework. Both derived, so history is covered without a backfill. returnedContributionCount = contributions.count { it.returnedCount > 0 }, @@ -153,7 +148,7 @@ class OnboardingMetricsService( return "A ${ContributionWording.NOUN} has been waiting $days days for a first response" } - // Something accepted already: onboarding is moving, whatever else is open. + // Something accepted already: their work is moving, whatever else is open. if (firstAcceptedAt != null) { return null } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampService.kt deleted file mode 100644 index 767fdf56..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampService.kt +++ /dev/null @@ -1,282 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.service - -import com.sprintstart.sprintstartbackend.onboarding.external.enums.CompetencySource -import com.sprintstart.sprintstartbackend.onboarding.external.enums.RampStage -import com.sprintstart.sprintstartbackend.onboarding.model.ContributionWording -import com.sprintstart.sprintstartbackend.onboarding.model.entity.AutonomyMilestone -import com.sprintstart.sprintstartbackend.onboarding.model.entity.Competency -import com.sprintstart.sprintstartbackend.onboarding.model.entity.UserCompetencyState -import com.sprintstart.sprintstartbackend.onboarding.model.mapper.toResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.ramp.AutonomyResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.ramp.MyRampResponse -import com.sprintstart.sprintstartbackend.onboarding.repository.AutonomyMilestoneRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.CompetencyRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.KnowledgeRequestRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.UserCompetencyStateRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.UserGoalRepository -import com.sprintstart.sprintstartbackend.user.external.ProjectMember -import com.sprintstart.sprintstartbackend.user.external.ProjectMembershipApi -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import org.springframework.web.server.ResponseStatusException -import java.time.Clock -import java.util.UUID - -/** - * The ramp of real tasks, and the exit from onboarding. - * - * Three decisions shape this service. - * - * The ledger is written by accepted work, not by chat. An accepted [Contribution] credits the - * competencies of the task it was claimed against, at that competency's own target level, with - * [CompetencySource.VERIFIED] and the same monotonic rule as every other ledger writer — it - * never lowers what somebody already showed. Chat placement stays a weak prior that accepted work - * outranks, never the other way round. What counts as accepted work is [ContributionService]'s - * question, not this service's: today every contribution is a merged pull request, and this - * service reads none of that detail. - * - * Task 0 credits nothing, by construction rather than by convention. Credit is derived from the - * *claimed goal*, and Task 0 is an assignment, not a goal — so there is no code path that could - * credit it. That is deliberate: its job is confidence and mechanics, and a ledger entry for - * "opened a pull request once" would be a lie about competence. - * - * Autonomy is an event, not a state. The exit condition is a task completed with no help from - * a person (an escalation to the PM, the surviving human channel) and no rework, which is - * the honest operational definition of "can be left alone here" — not "all nodes mastered". The - * moment is recorded once ([AutonomyMilestone]) so it can be announced and dated; recomputing it - * would only ever yield a boolean, and a boolean cannot be announced. - */ -@Service -class RampService( - private val taskZeroAssignmentRepository: TaskZeroAssignmentRepository, - private val userGoalRepository: UserGoalRepository, - private val starterWorkTaskProposalRepository: StarterWorkTaskProposalRepository, - private val userCompetencyStateRepository: UserCompetencyStateRepository, - private val competencyRepository: CompetencyRepository, - private val autonomyMilestoneRepository: AutonomyMilestoneRepository, - private val knowledgeRequestRepository: KnowledgeRequestRepository, - private val projectMembershipApi: ProjectMembershipApi, - private val contributionService: ContributionService, - // The board shows the same task on a card; which task somebody is on is not a question two - // readers should be able to answer differently. - private val currentTaskReader: CurrentTaskReader, - private val clock: Clock = Clock.systemUTC(), -) { - /** - * A hire's ramp on one project, crediting any merged work not yet credited. - * - * Crediting happens lazily on read for the same reason Task 0 assigns lazily: it covers work - * that merged while nobody was looking, with no scheduler and no backfill. It is idempotent — - * the ledger write is a monotonic find-or-create, so reading twice credits once. - * - * @throws ResponseStatusException 404 when the hire is not a member of the project. - */ - @Transactional - fun getForHire(hireId: UUID, projectId: UUID): MyRampResponse { - val member = requireMember(hireId, projectId) - val accepted = contributionService.forHire(member, projectId).filter { it.isAccepted } - - val credited = creditAcceptedWork(hireId, projectId, accepted) - val autonomy = evaluateAutonomy(hireId, projectId, accepted) - val currentTask = currentTaskReader.currentTaskFor(hireId, projectId) - - return MyRampResponse( - stage = stageOf(accepted.size, autonomy.reached), - currentTask = currentTask?.toResponse(), - unlockedBy = unlockedBy(accepted.size, autonomy.reached), - mergedCount = accepted.size, - creditedCompetencyKeys = credited, - autonomy = autonomy, - ) - } - - /** When a hire reached autonomy on a project, for the PM readout. Never writes. */ - @Transactional(readOnly = true) - fun autonomyReachedAtFor(hireId: UUID, projectId: UUID) = - autonomyMilestoneRepository.findByHireIdAndProjectId(hireId, projectId)?.reachedAt - - /** - * The stage a hire is on. - * - * Counted in *accepted contributions*, because that is the only unit of progress the ramp - * recognises. Task 0 is where somebody sits before anything of theirs has been accepted — - * including a hire with one contribution still in flight, since work nobody has taken has not - * proven the loop. - */ - private fun stageOf(acceptedCount: Int, autonomous: Boolean): RampStage = when { - autonomous -> RampStage.AUTONOMOUS - acceptedCount == 0 -> RampStage.TASK_ZERO - acceptedCount == 1 -> RampStage.TASK_ONE - else -> RampStage.TASK_TWO_PLUS - } - - /** - * What the hire has done, in their own track's words. - * - * An engineer reads "You merged your first change here"; a delivery lead reads about - * facilitated ceremonies. The sentence structure is fixed and only the nouns are supplied, - * which is the whole reason the vocabulary is structured fields rather than free text. - */ - private fun unlockedBy(acceptedCount: Int, autonomous: Boolean): String = when { - autonomous -> - "You ${ContributionWording.VERB_PAST} a ${ContributionWording.NOUN} with no help and no rework" - acceptedCount == 0 -> - "You haven't ${ContributionWording.VERB_PAST} anything here yet — that's the whole first step" - acceptedCount == 1 -> - "You ${ContributionWording.VERB_PAST} your first ${ContributionWording.NOUN} here" - else -> - "You've ${ContributionWording.VERB_PAST} $acceptedCount ${ContributionWording.NOUN_PLURAL} here" - } - - /** - * Writes ledger credit for competencies proven by accepted work. - * - * What acceptance is attributed to. Nothing links a contribution to the task it was for, so - * it is attributed to the goal the hire had *claimed at the time* — work accepted after the - * claim. That is an approximation and worth naming: without a task↔contribution link there is - * no exact answer, and the claimed goal is the best evidence available. It cannot over-credit - * an unrelated person's work, because attribution is enforced where contributions are built. - * - * @return The competency keys credited, for the hire to see what their work counted for. - */ - private fun creditAcceptedWork( - hireId: UUID, - projectId: UUID, - accepted: List, - ): List { - val goal = userGoalRepository.findByUserIdAndProjectId(hireId, projectId) ?: return emptyList() - val proposal = goal.sourceProposalId - ?.let { starterWorkTaskProposalRepository.findById(it).orElse(null) } - ?: return emptyList() - - val qualifying = accepted.any { it.acceptedAt?.isAfter(goal.claimedAt) == true } - if (!qualifying) return emptyList() - - val competencies = competencyRepository.findAllByKeyIn(proposal.competencyKeys).associateBy { it.key } - return proposal.competencyKeys.mapNotNull { key -> - val competency = competencies[key] ?: return@mapNotNull null - creditCompetency(hireId, competency) - key - } - } - - /** - * Monotonic find-or-create, mirroring `VerificationService`'s ledger write. - * - * Credit lands at the competency's own target level: accepted work is evidence of meeting - * the bar the project set for that competency, not of some level the work itself implies. - */ - private fun creditCompetency(hireId: UUID, competency: Competency) { - val existing = userCompetencyStateRepository.findByUserIdAndCompetencyKey(hireId, competency.key) - if (existing != null) { - // Never un-earns: accepted work cannot lower a level already shown, only raise it and - // upgrade the source to VERIFIED. - existing.level = maxOf(existing.level, competency.targetLevel) - existing.source = CompetencySource.VERIFIED - existing.updatedAt = clock.instant() - } else { - userCompetencyStateRepository.save( - UserCompetencyState( - userId = hireId, - competencyKey = competency.key, - level = competency.targetLevel, - source = CompetencySource.VERIFIED, - ), - ) - } - } - - /** - * Whether a hire has shown they can work here unsupervised, and what is missing if not. - * - * The condition is evaluated against the most recently accepted contribution, because - * autonomy is a claim about how somebody works now. Both halves must hold on that one task: it - * was not sent back, and no person was pulled in between submitting it and its acceptance. - */ - private fun evaluateAutonomy( - hireId: UUID, - projectId: UUID, - accepted: List, - ): AutonomyResponse { - autonomyMilestoneRepository.findByHireIdAndProjectId(hireId, projectId)?.let { - return AutonomyResponse( - reached = true, - reachedAt = it.reachedAt, - provenByArtifactId = it.provenByArtifactId, - blockers = emptyList(), - ) - } - - // Safe by the Contribution invariant: an ACCEPTED contribution always carries an acceptedAt. - val latest = accepted.maxByOrNull { it.acceptedAt!! } - ?: return AutonomyResponse( - reached = false, - reachedAt = null, - provenByArtifactId = null, - blockers = listOf( - "No ${ContributionWording.VERB_PAST} ${ContributionWording.NOUN} here yet", - ), - ) - - val blockers = mutableListOf() - if (latest.returnedCount > 0) { - blockers += - "Your last ${ContributionWording.VERB_PAST} ${ContributionWording.NOUN} was sent back for rework" - } - if (neededHelp(hireId, projectId, latest)) { - blockers += "You pulled in a person while you were on your last ${ContributionWording.NOUN}" - } - if (blockers.isNotEmpty()) { - return AutonomyResponse( - reached = false, - reachedAt = null, - provenByArtifactId = null, - blockers = blockers, - ) - } - - // Recorded at the acceptance itself, not at the moment we noticed -- the date has to be the - // one that actually happened, or the announcement is about our polling. - val milestone = autonomyMilestoneRepository.save( - AutonomyMilestone( - hireId = hireId, - projectId = projectId, - reachedAt = latest.acceptedAt!!, - provenByArtifactId = latest.evidenceRef, - ), - ) - return AutonomyResponse( - reached = true, - reachedAt = milestone.reachedAt, - provenByArtifactId = milestone.provenByArtifactId, - blockers = emptyList(), - ) - } - - /** - * Whether the hire needed a person while this contribution was in flight. - * - * Scoped to the contribution's own window rather than "ever": a hire who needed help in week - * one and delivered week four's work alone has demonstrated exactly what the exit condition - * asks about. "Help" is what the surviving human channel records: the assigned-buddy loop is - * retired, so this is now an escalation to the PM (flag-to-PM) during the window — the only - * reaching out a hire can still do. - */ - private fun neededHelp(hireId: UUID, projectId: UUID, contribution: Contribution): Boolean { - val opened = contribution.openedAt ?: return false - val accepted = contribution.acceptedAt ?: return false - return knowledgeRequestRepository - .findAllByHireIdAndProjectId(hireId, projectId) - .any { !it.createdAt.isBefore(opened) && !it.createdAt.isAfter(accepted) } - } - - private fun requireMember(hireId: UUID, projectId: UUID): ProjectMember = - projectMembershipApi.getProjectMembers(projectId).firstOrNull { it.userId == hireId } - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "User $hireId is not a member of project $projectId", - ) -} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationService.kt index 4f2ee47f..8b15c384 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationService.kt @@ -19,7 +19,6 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.orientation. import com.sprintstart.sprintstartbackend.onboarding.model.response.orientation.OrientationPacketResponse import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository import com.sprintstart.sprintstartbackend.onboarding.repository.TaskOrientationPacketRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository import com.sprintstart.sprintstartbackend.user.external.ProjectMembershipApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -55,8 +54,8 @@ import java.util.UUID * Nothing is ever fabricated. "No packet" is an ordinary returned state carrying the reason — * never an empty packet, never an error. * - * Reading orientation never assigns anything: the hire's current task is read straight from the - * assignment table, not through [TaskZeroService.getForHire], which assigns on read. + * Reading orientation never assigns anything: the task is the one the hire claimed, read through + * [CurrentTaskReader] -- the same read the board's current-task card uses. * * A human-authored packet is pinned [OrientationOrigin.HUMAN] and every cache rule above is * switched off for it: [getForHire] serves it as-is and never calls the AI, so it is never @@ -66,7 +65,7 @@ import java.util.UUID @Suppress("TooManyFunctions") class TaskOrientationService( private val taskOrientationPacketRepository: TaskOrientationPacketRepository, - private val taskZeroAssignmentRepository: TaskZeroAssignmentRepository, + private val currentTaskReader: CurrentTaskReader, private val starterWorkTaskProposalRepository: StarterWorkTaskProposalRepository, private val projectMembershipApi: ProjectMembershipApi, private val artifactIngestionApi: ArtifactIngestionApi, @@ -341,14 +340,11 @@ class TaskOrientationService( private fun resolveCurrentTaskProposal(hireId: UUID, projectId: UUID): StarterWorkTaskProposal { requireMember(hireId, projectId) - val assignment = taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId) + return currentTaskReader.currentTaskFor(hireId, projectId) ?: throw ResponseStatusException( HttpStatus.NOT_FOUND, "You have no current task to orient on project $projectId", ) - return starterWorkTaskProposalRepository.findById(assignment.proposalId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "No starter-work task found for your assignment") - } } private fun apply(context: TaskContext, outcome: OrientationOutcome): MyOrientationResponse { @@ -441,8 +437,7 @@ class TaskOrientationService( private fun loadContext(hireId: UUID, projectId: UUID): TaskContext? { requireMember(hireId, projectId) - val assignment = taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId) ?: return null - val proposal = starterWorkTaskProposalRepository.findById(assignment.proposalId).orElse(null) ?: return null + val proposal = currentTaskReader.currentTaskFor(hireId, projectId) ?: return null // The task's own words, not the one-line summary mining wrote about it -- when the issue it // was mined from is still ingested. diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroService.kt deleted file mode 100644 index 2dcf7854..00000000 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroService.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.service - -import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus -import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTaskProposal -import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskZeroAssignment -import com.sprintstart.sprintstartbackend.onboarding.model.mapper.toResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.MyTaskZeroResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.StarterWorkTaskProposalResponse -import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository -import com.sprintstart.sprintstartbackend.user.external.ProjectMember -import com.sprintstart.sprintstartbackend.user.external.ProjectMembershipApi -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import org.springframework.web.server.ResponseStatusException -import java.time.Clock -import java.time.Instant -import java.util.UUID - -/** - * Task 0: the trivial first task that proves the branch → PR → review → merge loop. - * - * Assignment is automatic on the hire's first read — a first day should never end with "pick - * something" — and undoable. Deliberately not gated on any environment-readiness signal: - * getting the project running is *part of* Task 0, not a wall in front of it, and gating the first - * task behind a setup check we can't reliably detect only strands a fresh hire (it also runs against - * the "no gates" rule). The task comes from the same live pool as every other, - * flagged as Task-0-suitable by a PM, because a task nobody wanted is not a - * contribution and hires can tell. - * - * Completing it proves the loop and nothing else: there is no `UserCompetencyState` write - * anywhere here, and this service does not depend on the ledger. "Loop proven" is derived — the - * hire has merged a pull request — never stored as earned competence. - */ -@Service -class TaskZeroService( - private val starterWorkTaskProposalRepository: StarterWorkTaskProposalRepository, - private val taskZeroAssignmentRepository: TaskZeroAssignmentRepository, - private val projectMembershipApi: ProjectMembershipApi, - private val contributionService: ContributionService, - private val clock: Clock = Clock.systemUTC(), -) { - /** - * Sets whether a live starter-work task is a Task 0 candidate. - * - * @throws ResponseStatusException 404 when no such proposal exists; 409 when it is not `LIVE` - * (Task-0 candidacy is a property of the pool, not of something somebody removed). - */ - @Transactional - fun setEligibility(proposalId: UUID, eligible: Boolean): StarterWorkTaskProposalResponse { - val proposal = starterWorkTaskProposalRepository.findById(proposalId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "No starter-work task found with id $proposalId") - } - if (proposal.status != ProposalStatus.LIVE) { - throw ResponseStatusException( - HttpStatus.CONFLICT, - "Only a live task can be flagged for Task 0", - ) - } - proposal.taskZeroEligible = eligible - return starterWorkTaskProposalRepository.save(proposal).toResponse() - } - - /** - * The hire's Task 0, assigning one on read if none is assigned yet. - * - * The assignment happens lazily here rather than on a background trigger. It is available from - * day one — no environment-readiness precondition — so `noneAvailable` (no PM has flagged a - * Task-0-suitable task yet) is the only "no task" state. - * - * @throws ResponseStatusException 404 when the hire is not a member of the project. - */ - @Transactional - fun getForHire(hireId: UUID, projectId: UUID): MyTaskZeroResponse { - val member = requireMember(hireId, projectId) - val loopProven = hasAcceptedContribution(member, projectId) - - taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId)?.let { existing -> - return existing.toResponse(loopProven) - } - - val candidate = nextEligibleTask() - ?: return MyTaskZeroResponse( - task = null, - assignedAt = null, - noneAvailable = true, - loopProven = loopProven, - ) - - val saved = taskZeroAssignmentRepository.save( - TaskZeroAssignment( - hireId = hireId, - projectId = projectId, - proposalId = candidate.id, - assignedAt = clock.instant(), - ), - ) - return MyTaskZeroResponse( - task = candidate.toResponse(), - assignedAt = saved.assignedAt, - noneAvailable = false, - loopProven = loopProven, - ) - } - - /** - * Undoes a hire's Task 0 assignment, freeing the task for someone else. A no-op when nothing is - * assigned. Touches no earned progress — there is none to un-earn. - */ - @Transactional - fun unassign(hireId: UUID, projectId: UUID) { - taskZeroAssignmentRepository.deleteByHireIdAndProjectId(hireId, projectId) - } - - /** When Task 0 was assigned, for the metrics timeline. Read-only, never assigns as a side effect. */ - @Transactional(readOnly = true) - fun assignedAtFor(hireId: UUID, projectId: UUID): Instant? = - taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId)?.assignedAt - - private fun nextEligibleTask(): StarterWorkTaskProposal? { - val taken = taskZeroAssignmentRepository.findAllAssignedProposalIds().toSet() - return starterWorkTaskProposalRepository - .findAllByStatusAndTaskZeroEligibleTrue(ProposalStatus.LIVE) - .filter { it.id !in taken } - .minByOrNull { it.createdAt } - } - - /** - * Whether anything this hire produced has been accepted here yet. - * - * This is the same question the ramp asks to decide somebody has left stage zero, so it reads - * the same contribution stream rather than re-deriving it from pull requests -- two answers to - * "have they completed anything" that could disagree is exactly the kind of drift that put - * "writes code" into the definition of progress in the first place. - */ - private fun hasAcceptedContribution(member: ProjectMember, projectId: UUID): Boolean { - return contributionService.forHire(member, projectId).any { it.isAccepted } - } - - private fun requireMember(hireId: UUID, projectId: UUID): ProjectMember = - projectMembershipApi.getProjectMembers(projectId).firstOrNull { it.userId == hireId } - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "User $hireId is not a member of project $projectId", - ) - - private fun TaskZeroAssignment.toResponse(loopProven: Boolean): MyTaskZeroResponse { - val proposal = starterWorkTaskProposalRepository.findById(proposalId).orElse(null) - return MyTaskZeroResponse( - task = proposal?.toResponse(), - assignedAt = assignedAt, - noneAvailable = false, - loopProven = loopProven, - ) - } -} diff --git a/src/main/resources/db/migration/V19__retire_legacy_onboarding.sql b/src/main/resources/db/migration/V19__retire_legacy_onboarding.sql new file mode 100644 index 00000000..c8f27811 --- /dev/null +++ b/src/main/resources/db/migration/V19__retire_legacy_onboarding.sql @@ -0,0 +1,17 @@ +-- The old onboarding -- Task 0 handed out on first read, the ramp, and "autonomy" as the end of +-- onboarding -- is gone: onboarding is the path a PM's blueprint prescribes, and the buddy tutors +-- along it (#311). Idempotent, and safe to run against a database ddl-auto has updated. + +-- Nothing reads these tables any more. +DROP TABLE IF EXISTS task_zero_assignments; +DROP TABLE IF EXISTS autonomy_milestones; + +-- task_zero_eligible stays for now: StarterWorkTaskProposal still maps it, because an existing +-- database holds it as NOT NULL with no default and inserts would fail without the field. The +-- default below lifts that; once it has run everywhere, delete the field and drop the column. +ALTER TABLE starter_work_task_proposals + ALTER COLUMN task_zero_eligible SET DEFAULT false; + +-- The joined -> first-accepted-work board card. RetiredBoardCardCleanup deletes these on startup +-- as well, because a row of a kind the enum no longer has fails every board read. +DELETE FROM board_cards WHERE kind = 'PATH_TO_FIRST_CONTRIBUTION'; diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReaderTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReaderTest.kt index 753fcfe9..7792ea7e 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReaderTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/AssignedIssueReaderTest.kt @@ -159,7 +159,7 @@ class AssignedIssueReaderTest { } /** - * Rework is half the operational definition of autonomy — "done, with nothing sent back" — + * Rework is what tells clean work from work sent back -- "done, with nothing sent back" -- * so a flat zero here would hand every tracked issue a clean run it had not earned. Somebody * else moving the issue out of a status the hire put it in is the tracker's version of a review * asking for changes. diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BoardControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BoardControllerTest.kt index 8ab4e89b..67dc5251 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BoardControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BoardControllerTest.kt @@ -7,11 +7,9 @@ import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardOwn import com.sprintstart.sprintstartbackend.onboarding.model.request.board.AuthoredCardRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.board.NoteCardRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardCardResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentKey -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.CurrentTaskContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.NoteContent -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathToFirstContributionContent import com.sprintstart.sprintstartbackend.onboarding.service.BoardDiagramService import com.sprintstart.sprintstartbackend.onboarding.service.BoardService import com.sprintstart.sprintstartbackend.user.external.UserApi @@ -79,15 +77,16 @@ class BoardControllerTest( cards = listOf( BoardCardResponse( id = UUID.randomUUID(), - kind = BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, + kind = BoardCardKind.CURRENT_TASK, owner = BoardCardOwner.AI, position = 0, placedAt = null, - content = PathToFirstContributionContent( - moments = listOf(BoardMomentResponse(BoardMomentKey.JOINED, null)), - acceptedCount = 0, - autonomyReachedAt = null, - stalledReason = null, + content = CurrentTaskContent( + taskId = null, + title = null, + summary = null, + url = null, + closedAtSource = false, ), ), ), @@ -112,9 +111,8 @@ class BoardControllerTest( .andExpect(status().isOk) // The card's kind must survive onto the wire as the content's discriminator, or a // client cannot tell which card it is rendering. - .andExpect(jsonPath("$.cards[0].content.kind").value("PATH_TO_FIRST_CONTRIBUTION")) - .andExpect(jsonPath("$.cards[0].content.moments[0].key").value("JOINED")) - .andExpect(jsonPath("$.cards[0].content.moments[0].reachedAt").doesNotExist()) + .andExpect(jsonPath("$.cards[0].content.kind").value("CURRENT_TASK")) + .andExpect(jsonPath("$.cards[0].content.taskId").doesNotExist()) } @Test diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt index 844fb12b..b08a982d 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt @@ -271,14 +271,14 @@ class BuddyControllerTest( @Test fun `performAction should return 200 with the outcome`() { coEvery { buddyActionService.perform(any(), any()) } returns - BuddyActionResponse(ok = true, message = "Task 0 is yours.") + BuddyActionResponse(ok = true, message = "You are now working toward it.") val asyncResult = mockMvc .perform( post("/api/v1/onboarding/me/buddy/actions") .with(userJwt) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(BuddyActionRequest(action = "claim_task_zero"))), + .content(objectMapper.writeValueAsString(BuddyActionRequest(action = "claim_goal"))), ).andExpect(request().asyncStarted()) .andReturn() @@ -286,7 +286,7 @@ class BuddyControllerTest( .perform(asyncDispatch(asyncResult)) .andExpect(status().isOk) .andExpect(jsonPath("$.ok").value(true)) - .andExpect(jsonPath("$.message").value("Task 0 is yours.")) + .andExpect(jsonPath("$.message").value("You are now working toward it.")) } @Test @@ -296,7 +296,7 @@ class BuddyControllerTest( post("/api/v1/onboarding/me/buddy/actions") .with(noUserRoleJwt) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(BuddyActionRequest(action = "claim_task_zero"))), + .content(objectMapper.writeValueAsString(BuddyActionRequest(action = "claim_goal"))), ).andExpect(request().asyncStarted()) .andReturn() diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingMetricsControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingMetricsControllerTest.kt index d8b5c321..fccd3c5f 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingMetricsControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingMetricsControllerTest.kt @@ -65,7 +65,6 @@ class OnboardingMetricsControllerTest( displayName = "Ada", githubLogin = "ada", joinedAt = null, - taskZeroAssignedAt = null, firstTaskClaimedAt = null, firstContributionOpenedAt = null, firstResponseAt = null, @@ -77,7 +76,6 @@ class OnboardingMetricsControllerTest( longestOpenWaitHours = 72, stalled = true, stalledReason = "Waiting on a review for 3 days", - autonomyReachedAt = null, returnedContributionCount = 0, ) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt index 649e89ea..851564b0 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt @@ -84,7 +84,6 @@ class StarterWorkControllerTest( sourceUrl = "https://github.com/org/repo/issues/1", competencyKeys = listOf("docs"), status = ProposalStatus.LIVE, - taskZeroEligible = false, ) @Test diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroControllerTest.kt deleted file mode 100644 index f86819a9..00000000 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/TaskZeroControllerTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.controller - -import com.ninjasquad.springmockk.MockkBean -import com.sprintstart.sprintstartbackend.config.SecurityConfig -import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.MyTaskZeroResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.StarterWorkTaskProposalResponse -import com.sprintstart.sprintstartbackend.onboarding.service.TaskZeroService -import com.sprintstart.sprintstartbackend.user.external.UserApi -import io.mockk.every -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest -import org.springframework.context.annotation.Import -import org.springframework.http.MediaType -import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.oauth2.jwt.JwtDecoder -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import java.util.Optional -import java.util.UUID - -@WebMvcTest(TaskZeroController::class) -@Import(SecurityConfig::class) -@AutoConfigureMockMvc -class TaskZeroControllerTest( - @Autowired private val mockMvc: MockMvc, -) { - @MockkBean - private lateinit var taskZeroService: TaskZeroService - - @MockkBean - private lateinit var userApi: UserApi - - @MockkBean - private lateinit var jwtDecoder: JwtDecoder - - private val authId = "test-auth-id" - private val userId = UUID.randomUUID() - private val projectId = UUID.randomUUID() - private val proposalId = UUID.randomUUID() - - private fun jwtWithSubject(subject: String, vararg roles: String): JwtRequestPostProcessor = - jwt() - .jwt { jwt -> - jwt.subject(subject) - jwt.claim("realm_access", mapOf("roles" to roles.toList())) - }.authorities(roles.map { SimpleGrantedAuthority("ROLE_$it") }) - - private val userJwt = jwtWithSubject(authId, "USER") - private val pmJwt = jwtWithSubject(authId, "PM") - - @Test - fun `getMyTaskZero returns the caller's task-zero state`() { - every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) - every { taskZeroService.getForHire(userId, projectId) } returns - MyTaskZeroResponse(task = null, assignedAt = null, noneAvailable = true, loopProven = false) - - mockMvc - .perform(get("/api/v1/onboarding/me/task-zero").param("projectId", projectId.toString()).with(userJwt)) - .andExpect(status().isOk) - .andExpect(jsonPath("$.noneAvailable").value(true)) - } - - @Test - fun `getMyTaskZero requires authentication`() { - mockMvc - .perform(get("/api/v1/onboarding/me/task-zero").param("projectId", projectId.toString())) - .andExpect(status().isUnauthorized) - } - - @Test - fun `setEligibility is allowed for a PM`() { - every { taskZeroService.setEligibility(proposalId, true) } returns - StarterWorkTaskProposalResponse( - id = proposalId, - sourceId = "github:org/repo:ISSUE:1", - title = "Fix a typo", - summary = null, - rationale = null, - sourceUrl = null, - competencyKeys = emptyList(), - status = ProposalStatus.LIVE, - taskZeroEligible = true, - ) - - mockMvc - .perform( - post("/api/v1/onboarding/starter-work/$proposalId/task-zero") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"eligible":true}""") - .with(pmJwt), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.taskZeroEligible").value(true)) - - verify(exactly = 1) { taskZeroService.setEligibility(proposalId, true) } - } - - @Test - fun `setEligibility is forbidden for a plain user`() { - mockMvc - .perform( - post("/api/v1/onboarding/starter-work/$proposalId/task-zero") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"eligible":true}""") - .with(userJwt), - ).andExpect(status().isForbidden) - } -} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListenerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListenerTest.kt index 44621fc8..12f484c1 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListenerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/listener/UserDeletedListenerTest.kt @@ -4,7 +4,6 @@ import com.sprintstart.sprintstartbackend.onboarding.model.entity.Board import com.sprintstart.sprintstartbackend.onboarding.model.entity.BuddySession import com.sprintstart.sprintstartbackend.onboarding.repository.ArrivalStepStateRepository import com.sprintstart.sprintstartbackend.onboarding.repository.AttestationRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.AutonomyMilestoneRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardCardRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardStructureRepository @@ -12,7 +11,6 @@ import com.sprintstart.sprintstartbackend.onboarding.repository.BuddyMessageRepo import com.sprintstart.sprintstartbackend.onboarding.repository.BuddySessionRepository import com.sprintstart.sprintstartbackend.onboarding.repository.GithubHistoryPriorRepository import com.sprintstart.sprintstartbackend.onboarding.repository.KnowledgeRequestRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository import com.sprintstart.sprintstartbackend.onboarding.repository.UserCompetencyStateRepository import com.sprintstart.sprintstartbackend.onboarding.repository.UserGoalRepository import com.sprintstart.sprintstartbackend.user.external.events.UserDeletedEvent @@ -33,8 +31,6 @@ class UserDeletedListenerTest { private val boardCardRepository: BoardCardRepository = mockk(relaxed = true) private val boardStructureRepository: BoardStructureRepository = mockk(relaxed = true) private val userGoalRepository: UserGoalRepository = mockk(relaxed = true) - private val taskZeroAssignmentRepository: TaskZeroAssignmentRepository = mockk(relaxed = true) - private val autonomyMilestoneRepository: AutonomyMilestoneRepository = mockk(relaxed = true) private val attestationRepository: AttestationRepository = mockk(relaxed = true) private val knowledgeRequestRepository: KnowledgeRequestRepository = mockk(relaxed = true) private val githubHistoryPriorRepository: GithubHistoryPriorRepository = mockk(relaxed = true) @@ -48,8 +44,6 @@ class UserDeletedListenerTest { boardCardRepository, boardStructureRepository, userGoalRepository, - taskZeroAssignmentRepository, - autonomyMilestoneRepository, attestationRepository, knowledgeRequestRepository, githubHistoryPriorRepository, @@ -89,8 +83,6 @@ class UserDeletedListenerTest { verify { userCompetencyStateRepository.deleteAllByUserId(userId) } verify { arrivalStepStateRepository.deleteAllByUserId(userId) } verify { userGoalRepository.deleteAllByUserId(userId) } - verify { taskZeroAssignmentRepository.deleteAllByHireId(userId) } - verify { autonomyMilestoneRepository.deleteAllByHireId(userId) } verify { attestationRepository.deleteAllByHireId(userId) } verify { knowledgeRequestRepository.deleteAllByHireId(userId) } verify { githubHistoryPriorRepository.deleteAllByUserId(userId) } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt index 047d3c2f..ee3652cc 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt @@ -17,16 +17,12 @@ import com.sprintstart.sprintstartbackend.onboarding.model.entity.BoardCard import com.sprintstart.sprintstartbackend.onboarding.model.entity.BuddySession import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTaskProposal import com.sprintstart.sprintstartbackend.onboarding.model.response.board.ArrivalStepsContent -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentKey -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.CompetencyProgressContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.CurrentTaskContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.MemoryRecapContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.OpenPullRequestsContent -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathToFirstContributionContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.SuggestedTasksContent import com.sprintstart.sprintstartbackend.onboarding.model.response.competency.MyCompetencyResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.metrics.HireTimelineResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.RankedStarterWorkTaskResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.StarterWorkTaskProposalResponse import com.sprintstart.sprintstartbackend.onboarding.repository.BoardCardRepository @@ -65,7 +61,6 @@ class BoardServiceTest { private val boardRepository: BoardRepository = mockk() private val boardCardRepository: BoardCardRepository = mockk() private val projectMembershipApi: ProjectMembershipApi = mockk() - private val onboardingMetricsService: OnboardingMetricsService = mockk() private val artifactIngestionApi: ArtifactIngestionApi = mockk() private val currentTaskReader: CurrentTaskReader = mockk() private val starterWorkTaskProposalService: StarterWorkTaskProposalService = mockk() @@ -97,7 +92,6 @@ class BoardServiceTest { boardRepository, boardCardRepository, projectMembershipApi, - onboardingMetricsService, OpenPullRequestReader(artifactIngestionApi, Clock.fixed(now, ZoneOffset.UTC)), currentTaskReader, starterWorkTaskProposalService, @@ -111,14 +105,12 @@ class BoardServiceTest { @BeforeEach fun setUp() { every { projectMembershipApi.getProjectMembers(projectId) } returns listOf(member()) - every { onboardingMetricsService.getHireTimeline(hireId, projectId) } returns timeline() every { artifactIngestionApi.getAuthoredPullRequests(projectId, "ada") } returns emptyList() every { boardRepository.save(any()) } answers { firstArg() } every { boardCardRepository.saveAll(any>()) } answers { firstArg() } every { boardCardRepository.findAllByBoardId(any()) } returns emptyList() every { boardCardRepository.save(any()) } answers { firstArg() } every { currentTaskReader.currentTaskFor(hireId, projectId) } returns null - every { currentTaskReader.isClaimedGoal(hireId, projectId) } returns false every { starterWorkTaskProposalService.matchForUserId(hireId, projectId) } returns emptyList() every { myCompetencyService.getCompetenciesForUser(hireId) } returns emptyList() every { buddySessionRepository.findByUserId(hireId) } returns null @@ -135,36 +127,6 @@ class BoardServiceTest { joinedAt = joinedAt, ) - @Suppress("LongParameterList") - private fun timeline( - firstTaskClaimedAt: Instant? = null, - firstOpenedAt: Instant? = null, - firstResponseAt: Instant? = null, - acceptedAt: Instant? = null, - acceptedCount: Int = 0, - stalledReason: String? = null, - autonomyReachedAt: Instant? = null, - ) = HireTimelineResponse( - userId = hireId, - displayName = "Ada", - githubLogin = "ada", - joinedAt = now.minusSeconds(86_400), - taskZeroAssignedAt = null, - firstTaskClaimedAt = firstTaskClaimedAt, - firstContributionOpenedAt = firstOpenedAt, - firstResponseAt = firstResponseAt, - firstContributionAcceptedAt = acceptedAt, - hoursToFirstAcceptedContribution = null, - hoursToFirstResponse = null, - acceptedContributionCount = acceptedCount, - openContributionCount = 0, - longestOpenWaitHours = null, - stalled = stalledReason != null, - stalledReason = stalledReason, - autonomyReachedAt = autonomyReachedAt, - returnedContributionCount = 0, - ) - private fun existingBoard(): Board { val board = Board(userId = hireId, projectId = projectId) every { boardRepository.findByUserIdAndProjectId(hireId, projectId) } returns board @@ -193,15 +155,12 @@ class BoardServiceTest { } @Test - fun `an engineering hire gets the path card and the open pull request card`() { + fun `an engineering hire gets the open pull request card`() { noBoardYet() val kinds = service.getBoard(hireId, projectId)?.cards?.map { it.kind } - assertEquals( - listOf(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, BoardCardKind.OPEN_PULL_REQUESTS), - kinds, - ) + assertEquals(listOf(BoardCardKind.OPEN_PULL_REQUESTS), kinds) } @Test @@ -247,12 +206,17 @@ class BoardServiceTest { /** * Attention ordering: a hire who cannot clone the repository should not have to scroll to find - * out what to do about it. The arrival card is ensured *after* the others, so without this it - * lands last — the worst possible position for the most urgent thing. + * out what to do about it. On a board that existed before the step was authored the arrival card + * is added *after* the others, so without this it lands last — the worst possible position for + * the most urgent thing. */ @Test fun `an outstanding arrival step puts its card first`() { - noBoardYet() + val board = existingBoard() + every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( + card(board, BoardCardKind.OPEN_PULL_REQUESTS, position = 0), + card(board, BoardCardKind.ARRIVAL_STEPS, position = 1), + ) every { arrivalStepService.forHire(hireId) } returns listOf( ResolvedArrivalStep( step = ArrivalStep(key = "vpn", title = "Request VPN access"), @@ -273,7 +237,11 @@ class BoardServiceTest { */ @Test fun `a fully settled arrival card takes its ordinary place again`() { - noBoardYet() + val board = existingBoard() + every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( + card(board, BoardCardKind.OPEN_PULL_REQUESTS, position = 0), + card(board, BoardCardKind.ARRIVAL_STEPS, position = 1), + ) every { arrivalStepService.forHire(hireId) } returns listOf( ResolvedArrivalStep( step = ArrivalStep(key = "vpn", title = "Request VPN access"), @@ -284,7 +252,7 @@ class BoardServiceTest { val kinds = service.getBoard(hireId, projectId)?.cards?.map { it.kind } - assertEquals(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, kinds!!.first()) + assertEquals(BoardCardKind.OPEN_PULL_REQUESTS, kinds!!.first()) assertTrue(kinds.contains(BoardCardKind.ARRIVAL_STEPS)) } @@ -296,7 +264,7 @@ class BoardServiceTest { fun `the task the hire is on comes first`() { val board = existingBoard() every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( - card(board, BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, position = 0), + card(board, BoardCardKind.MEMORY_RECAP, position = 0), card(board, BoardCardKind.OPEN_PULL_REQUESTS, position = 1), card(board, BoardCardKind.CURRENT_TASK, position = 2), ) @@ -311,7 +279,7 @@ class BoardServiceTest { // Underneath, their own arrangement is untouched -- the pin is a sort on read, never a // write to `position`, which is what makes overriding it acceptable rather than destructive. assertEquals( - listOf(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, BoardCardKind.OPEN_PULL_REQUESTS), + listOf(BoardCardKind.MEMORY_RECAP, BoardCardKind.OPEN_PULL_REQUESTS), kinds.drop(1), ) } @@ -324,7 +292,7 @@ class BoardServiceTest { fun `a hire on no task gets their own order back`() { val board = existingBoard() every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( - card(board, BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, position = 0), + card(board, BoardCardKind.MEMORY_RECAP, position = 0), card(board, BoardCardKind.OPEN_PULL_REQUESTS, position = 1), card(board, BoardCardKind.CURRENT_TASK, position = 2), ) @@ -336,7 +304,7 @@ class BoardServiceTest { // best place on the board for having nothing on it. assertEquals( listOf( - BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, + BoardCardKind.MEMORY_RECAP, BoardCardKind.OPEN_PULL_REQUESTS, BoardCardKind.CURRENT_TASK, ), @@ -388,7 +356,6 @@ class BoardServiceTest { fun `a busy board loses nothing to the ordering`() { val board = existingBoard() val kindsOnBoard = listOf( - BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, BoardCardKind.OPEN_PULL_REQUESTS, BoardCardKind.CURRENT_TASK, BoardCardKind.SUGGESTED_TASKS, @@ -408,32 +375,15 @@ class BoardServiceTest { assertEquals(kindsOnBoard.toSet(), kinds.toSet()) } - @Test - fun `the path card is placed for every track, because its moments are not about git`() { - noBoardYet() - - val board = service.getBoard(hireId, projectId) - - assertTrue( - board?.cards.orEmpty().any { it.kind == BoardCardKind.PATH_TO_FIRST_CONTRIBUTION }, - ) - } - @Test fun `ensuring cards is idempotent — a second read adds nothing`() { val board = existingBoard() every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( - BoardCard( - boardId = board.id, - kind = BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, - owner = BoardCardOwner.AI, - position = 0, - ), BoardCard( boardId = board.id, kind = BoardCardKind.OPEN_PULL_REQUESTS, owner = BoardCardOwner.AI, - position = 1, + position = 0, ), ) @@ -457,9 +407,9 @@ class BoardServiceTest { val kinds = service.getBoard(hireId, projectId)?.cards?.map { it.kind } - // The dismissed row is what makes the removal stick: the path card is added because it is - // missing, the pull-request card is not re-added because the hire said no to it. - assertEquals(listOf(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION), kinds) + // The dismissed row is what makes the removal stick: the pull-request card is not re-added + // because the hire said no to it. + assertEquals(emptyList(), kinds) } @Test @@ -468,7 +418,7 @@ class BoardServiceTest { every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( BoardCard( boardId = board.id, - kind = BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, + kind = BoardCardKind.MEMORY_RECAP, owner = BoardCardOwner.AI, position = 7, ), @@ -481,58 +431,6 @@ class BoardServiceTest { assertEquals(8, cards.last().position) } - @Test - fun `an unreached moment is absent, never zero`() { - noBoardYet() - - val content = service.pathCard() - - assertEquals(now.minusSeconds(86_400), content.moments.momentAt(BoardMomentKey.JOINED)) - assertNull(content.moments.momentAt(BoardMomentKey.WORK_ACCEPTED)) - assertEquals(0, content.acceptedCount) - } - - @Test - fun `the path card reports the moments the timeline has reached`() { - noBoardYet() - val accepted = now.minusSeconds(3_600) - every { onboardingMetricsService.getHireTimeline(hireId, projectId) } returns timeline( - firstTaskClaimedAt = now.minusSeconds(50_000), - firstOpenedAt = now.minusSeconds(20_000), - firstResponseAt = now.minusSeconds(10_000), - acceptedAt = accepted, - acceptedCount = 2, - autonomyReachedAt = accepted, - ) - - val content = service.pathCard() - - assertEquals(accepted, content.moments.momentAt(BoardMomentKey.WORK_ACCEPTED)) - assertEquals(2, content.acceptedCount) - assertEquals(accepted, content.autonomyReachedAt) - } - - @Test - fun `a hire with no timeline still gets the card, with nothing reached`() { - noBoardYet() - every { onboardingMetricsService.getHireTimeline(hireId, projectId) } returns null - - val content = service.pathCard() - - // Day one is a real state, and the card that describes it must exist on day one. - assertEquals(now.minusSeconds(86_400), content.moments.momentAt(BoardMomentKey.JOINED)) - assertTrue(content.moments.drop(1).all { it.reachedAt == null }) - } - - @Test - fun `a stall is shown to the person in it`() { - noBoardYet() - every { onboardingMetricsService.getHireTimeline(hireId, projectId) } returns - timeline(stalledReason = "no response in 5 days") - - assertEquals("no response in 5 days", service.pathCard().stalledReason) - } - @Test fun `open pull requests are listed longest-waiting first, with the answered one not waiting`() { noBoardYet() @@ -706,13 +604,10 @@ class BoardServiceTest { summary = "It fails about one run in five.", sourceUrl = "https://example.test/issues/7", ) - every { currentTaskReader.isClaimedGoal(hireId, projectId) } returns true val content = service.currentTaskCard() assertEquals("Fix the flaky login test", content.title) - // Chosen, not handed: only one of those is theirs to change their mind about. - assertTrue(content.chosen) } @Test @@ -726,7 +621,6 @@ class BoardServiceTest { // A card that vanishes when the goal is cleared reads as the board losing things. assertNull(content.taskId) - assertFalse(content.chosen) } @Test @@ -858,7 +752,6 @@ class BoardServiceTest { sourceUrl = null, competencyKeys = emptyList(), status = ProposalStatus.LIVE, - taskZeroEligible = false, ), score = 1.0, matchedCompetencyKeys = emptyList(), @@ -878,18 +771,9 @@ class BoardServiceTest { .first { it.kind == BoardCardKind.SUGGESTED_TASKS } .content as SuggestedTasksContent - private fun BoardService.pathCard(): PathToFirstContributionContent = - getBoard(hireId, projectId)!! - .cards - .first { it.kind == BoardCardKind.PATH_TO_FIRST_CONTRIBUTION } - .content as PathToFirstContributionContent - private fun BoardService.pullRequestCard(): OpenPullRequestsContent = getBoard(hireId, projectId)!! .cards .first { it.kind == BoardCardKind.OPEN_PULL_REQUESTS } .content as OpenPullRequestsContent - - private fun List.momentAt(key: BoardMomentKey): Instant? = - first { it.key == key }.reachedAt } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt index 9e9924b8..8451f87d 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt @@ -2,18 +2,16 @@ package com.sprintstart.sprintstartbackend.onboarding.service import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardKind import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProficiencyLevel -import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto import com.sprintstart.sprintstartbackend.onboarding.model.request.buddy.BuddyActionRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.goal.GoalView import com.sprintstart.sprintstartbackend.onboarding.model.response.orientation.MyOrientationResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.MyTaskZeroResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.StarterWorkTaskProposalResponse import com.sprintstart.sprintstartbackend.user.external.UserApi import com.sprintstart.sprintstartbackend.user.external.dto.ProjectDto import com.sprintstart.sprintstartbackend.user.external.dto.UserDto import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -25,12 +23,10 @@ import org.junit.jupiter.api.Test import org.springframework.http.HttpStatus import org.springframework.security.oauth2.jwt.Jwt import org.springframework.web.server.ResponseStatusException -import java.time.Instant import java.util.Optional import java.util.UUID class BuddyActionServiceTest { - private val taskZeroService: TaskZeroService = mockk() private val taskOrientationService: TaskOrientationService = mockk() private val knowledgeBaseService: KnowledgeBaseService = mockk(relaxed = true) private val userGoalService: UserGoalService = mockk() @@ -51,7 +47,6 @@ class BuddyActionServiceTest { every { handles(any()) } returns false } private val service = BuddyActionService( - taskZeroService, taskOrientationService, knowledgeBaseService, userGoalService, @@ -95,25 +90,6 @@ class BuddyActionServiceTest { arguments = buildJsonObject { args.forEach { (k, v) -> put(k, v) } }, ) - private fun taskZero(title: String?) = MyTaskZeroResponse( - task = title?.let { - StarterWorkTaskProposalResponse( - id = UUID.randomUUID(), - sourceId = "src", - title = it, - summary = null, - rationale = null, - sourceUrl = null, - competencyKeys = emptyList(), - status = ProposalStatus.LIVE, - taskZeroEligible = true, - ) - }, - assignedAt = title?.let { Instant.EPOCH }, - noneAvailable = title == null, - loopProven = false, - ) - // -- specs / dispatch ------------------------------------------------------------------------- @Test @@ -122,7 +98,6 @@ class BuddyActionServiceTest { assertThat(service.actionSpecs(userId).map { it.name }).containsExactlyInAnyOrder( "flag_to_pm", - "claim_task_zero", "open_orientation", "claim_goal", "request_attestation", @@ -142,23 +117,23 @@ class BuddyActionServiceTest { @Test fun `recognises action tools and rejects read tools`() { - assertThat(service.isAction("claim_task_zero")).isTrue() + assertThat(service.isAction("open_orientation")).isTrue() assertThat(service.isAction("get_my_metrics")).isFalse() } // -- propose (must never mutate) -------------------------------------------------------------- @Test - fun `proposes claim Task 0 with its confirm label and no mutation`() { + fun `proposes opening the task packet with its confirm label and no work done`() { onOneProject() - val outcome = service.propose(call("claim_task_zero"), userId) + val outcome = service.propose(call("open_orientation"), userId) - assertThat(outcome.proposal?.action).isEqualTo("claim_task_zero") - assertThat(outcome.proposal?.label).isEqualTo("Start Task 0") + assertThat(outcome.proposal?.action).isEqualTo("open_orientation") + assertThat(outcome.proposal?.label).isEqualTo("Open the task packet") assertThat(outcome.toolResult).contains("confirm") - // Proposing must not touch the assignment. - verify(exactly = 0) { taskZeroService.getForHire(any(), any()) } + // Proposing must not assemble anything. + coVerify(exactly = 0) { taskOrientationService.getForHire(any(), any()) } } @Test @@ -188,7 +163,7 @@ class BuddyActionServiceTest { fun `does not propose an action when the hire is on no project`() { every { userApi.getUsersByIds(listOf(userId)) } returns listOf(userWith()) - val outcome = service.propose(call("claim_task_zero"), userId) + val outcome = service.propose(call("open_orientation"), userId) assertThat(outcome.proposal).isNull() assertThat(outcome.toolResult).contains("not on a project") @@ -456,30 +431,6 @@ class BuddyActionServiceTest { // -- perform (the confirm round-trip) --------------------------------------------------------- - @Test - fun `claiming Task 0 assigns it and reports the title`() = runTest { - asHire() - onOneProject() - every { taskZeroService.getForHire(userId, projectId) } returns taskZero("Fix the login redirect") - - val result = service.perform(BuddyActionRequest(action = "claim_task_zero"), jwt) - - assertThat(result.ok).isTrue() - assertThat(result.message).contains("Fix the login redirect") - } - - @Test - fun `claiming Task 0 legibly reports when none is eligible`() = runTest { - asHire() - onOneProject() - every { taskZeroService.getForHire(userId, projectId) } returns taskZero(null) - - val result = service.perform(BuddyActionRequest(action = "claim_task_zero"), jwt) - - assertThat(result.ok).isFalse() - assertThat(result.message).contains("no eligible Task 0") - } - @Test fun `flagging to the PM escalates the question`() = runTest { asHire() @@ -607,10 +558,10 @@ class BuddyActionServiceTest { fun `a precondition failure downstream comes back as a legible reason`() = runTest { asHire() onOneProject() - every { taskZeroService.getForHire(userId, projectId) } throws + coEvery { taskOrientationService.getForHire(userId, projectId) } throws ResponseStatusException(HttpStatus.NOT_FOUND, "You are not a member of that project.") - val result = service.perform(BuddyActionRequest(action = "claim_task_zero"), jwt) + val result = service.perform(BuddyActionRequest(action = "open_orientation"), jwt) assertThat(result.ok).isFalse() assertThat(result.message).contains("not a member") diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardToolsTest.kt index 1909f5e9..c7dfb6bd 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardToolsTest.kt @@ -151,7 +151,7 @@ class BuddyBoardToolsTest { @Test fun `the mentor cannot place a card the board already keeps by itself`() { - val result = tools.execute(placeCall("PATH_TO_FIRST_CONTRIBUTION"), userId) + val result = tools.execute(placeCall("ARRIVAL_STEPS"), userId) // Offering a baseline kind would only let the model take credit for a card that was there // anyway. @@ -320,6 +320,6 @@ class BuddyBoardToolsTest { assertThat(spec.name).isEqualTo("place_card") assertThat(spec.description).contains("CURRENT_TASK", "SUGGESTED_TASKS") // The description must not offer a baseline card as something to place. - assertThat(spec.description).doesNotContain("PATH_TO_FIRST_CONTRIBUTION") + assertThat(spec.description).doesNotContain("ARRIVAL_STEPS") } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt index 24ff880f..11361b46 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActionTest.kt @@ -65,7 +65,6 @@ class BuddyPathActionTest { ) private val service = BuddyActionService( - taskZeroService = mockk(relaxed = true), taskOrientationService = mockk(relaxed = true), knowledgeBaseService = mockk(relaxed = true), userGoalService = mockk(relaxed = true), diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt index 6c8cc5fc..b3a5a19a 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathStepActionTest.kt @@ -65,7 +65,6 @@ class BuddyPathStepActionTest { ) private val service = BuddyActionService( - taskZeroService = mockk(relaxed = true), taskOrientationService = mockk(relaxed = true), knowledgeBaseService = mockk(relaxed = true), userGoalService = mockk(relaxed = true), diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt index c5232139..1c3f4b18 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyServiceTest.kt @@ -742,7 +742,7 @@ class BuddyServiceTest { every { buddyMessageRepository.save(any()) } answers { firstArg() } every { buddyToolExecutor.toolSpecs(any()) } returns emptyList() - val actionCall = BuddyToolCallDto(id = "call_0", name = "claim_task_zero") + val actionCall = BuddyToolCallDto(id = "call_0", name = "open_orientation") val paused = BuddyAgentResponse( final = false, messages = listOf( @@ -753,15 +753,15 @@ class BuddyServiceTest { val requests = mutableListOf() coEvery { onboardingAiClient.buddyAgentTurn(capture(requests)) } returnsMany listOf( paused, - finalReply("I can start Task 0 for you — confirm below."), + finalReply("I can open the task packet for you — confirm below."), ) - every { buddyActionService.isAction("claim_task_zero") } returns true + every { buddyActionService.isAction("open_orientation") } returns true every { buddyActionService.propose(actionCall, userId) } returns BuddyActionService.ProposeOutcome( toolResult = "Proposed to the hire; awaiting confirmation.", proposal = BuddyActionService.BuddyActionProposal( - action = "claim_task_zero", - label = "Start Task 0", + action = "open_orientation", + label = "Open the task packet", question = null, ), ) @@ -770,8 +770,8 @@ class BuddyServiceTest { // The proposal is emitted as its own gate-able event, carrying the action + button label... val proposal = events.first { it.type == "action_proposal" } - assertThat(proposal.action).isEqualTo("claim_task_zero") - assertThat(proposal.label).isEqualTo("Start Task 0") + assertThat(proposal.action).isEqualTo("open_orientation") + assertThat(proposal.label).isEqualTo("Open the task packet") // ...the tool result (not a mutation) is threaded back into the resume conversation... assertThat(requests[1].messages).contains( BuddyAgentMessageDto( diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt index 2fd998e3..c6132f68 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt @@ -33,7 +33,7 @@ class BuddySuggestionServiceTest { ) assertThat(service.forHire(userId).map { it.label }) - .containsExactly("How am I doing?", "What should I work on?") + .containsExactly("How's my work going?", "What should I work on?") } @Test @@ -98,20 +98,35 @@ class BuddySuggestionServiceTest { } /** - * Arrival is first in the spec list because what has to be true before somebody can work comes - * before how their work is going. Chips inherit that ordering rather than having an opinion of - * their own — two orderings would eventually disagree. + * The path is first in the spec list because it is the onboarding, and setup comes before how + * their work is going. Chips inherit that ordering rather than having an opinion of their own — + * two orderings would eventually disagree. */ @Test fun `keeps the order the tools are mounted in`() { mounted( + BuddyPathTools.READ_MY_PATH, BuddyToolExecutor.GET_ARRIVAL_STEPS, BuddyToolExecutor.GET_MY_METRICS, BuddyToolExecutor.GET_SUGGESTED_TASKS, ) - assertThat(service.forHire(userId).map { it.label }) - .containsExactly("What do I still need?", "How am I doing?", "What should I work on?") + assertThat(service.forHire(userId).map { it.label }).containsExactly( + "Where am I on my path?", + "What do I still need?", + "How's my work going?", + "What should I work on?", + ) + } + + /** "How is my onboarding going?" belongs to the path; the metrics chip must not ask it. */ + @Test + fun `the metrics chip asks about work, not onboarding`() { + mounted(BuddyToolExecutor.GET_MY_METRICS) + + assertThat(service.forHire(userId)).singleElement().satisfies({ + assertThat((it.label + " " + it.question).lowercase()).doesNotContain("onboarding") + }) } /** diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt index 4b900e20..fc99b44c 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt @@ -8,6 +8,7 @@ import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStat import com.sprintstart.sprintstartbackend.onboarding.external.enums.Rigor import com.sprintstart.sprintstartbackend.onboarding.external.enums.TaskType import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto import com.sprintstart.sprintstartbackend.onboarding.model.entity.ArrivalStep import com.sprintstart.sprintstartbackend.onboarding.model.entity.CanonicalAnswer import com.sprintstart.sprintstartbackend.onboarding.model.response.competency.MyCompetencyResponse @@ -166,7 +167,6 @@ class BuddyToolExecutorTest { displayName = "Sam Hire", githubLogin = "sam", joinedAt = null, - taskZeroAssignedAt = null, firstTaskClaimedAt = null, firstContributionOpenedAt = null, firstResponseAt = null, @@ -178,7 +178,6 @@ class BuddyToolExecutorTest { longestOpenWaitHours = longestOpenWaitHours, stalled = stalled, stalledReason = stalledReason, - autonomyReachedAt = null, returnedContributionCount = 0, ) @@ -210,7 +209,6 @@ class BuddyToolExecutorTest { sourceUrl = sourceUrl, competencyKeys = emptyList(), status = ProposalStatus.LIVE, - taskZeroEligible = false, ), score = 1.0, matchedCompetencyKeys = emptyList(), @@ -283,14 +281,19 @@ class BuddyToolExecutorTest { } /** - * First in the list, and that is the point of the slice. The failure this initiative exists - * to fix is somebody who cannot clone the repository being handed a good first issue. + * The path is the onboarding, so it is read first; setup comes right after it and before + * anything about how their work is going -- somebody who cannot get in should not be handed a + * good first issue. */ @Test - fun `arrival comes before every other hire-state tool`() { + fun `the path comes first, and arrival before every other hire-state tool`() { + every { buddyPathTools.toolSpecs(userId) } returns listOf( + BuddyToolSpecDto(name = "get_my_onboarding_path", description = "", parameters = buildJsonObject { }), + ) every { arrivalStepService.forHire(userId) } returns listOf(resolvedStep()) - assertThat(executor.toolSpecs(userId).map { it.name }).startsWith("get_arrival_steps") + assertThat(executor.toolSpecs(userId).map { it.name }) + .startsWith("get_my_onboarding_path", "get_arrival_steps") } @Test @@ -354,12 +357,13 @@ class BuddyToolExecutorTest { } /** - * Order is the feature. A greeting grounded in progress before setup is exactly the failure - * this initiative exists to fix — and it reads as calm, because the stall detector watches - * contributions rather than access. + * Order is the feature. The greeting opens on the path, because that is the onboarding; and a + * greeting grounded in progress before setup is the failure the arrival list exists to fix — + * it reads as calm, because the stall detector watches contributions rather than access. */ @Test - fun `the opening greeting is grounded in what is missing before anything else`() { + fun `the opening greeting is grounded in the path, then what is missing, then progress`() { + every { buddyPathTools.snapshotFor(userId) } returns "Onboarding path: phase 1 of 3" every { arrivalStepService.forHire(userId) } returns listOf(resolvedStep()) every { userApi.getUsersByIds(listOf(userId)) } returns listOf(userWith()) // No projects, so metrics and suggestions short-circuit; only the ledger is reached. @@ -368,8 +372,9 @@ class BuddyToolExecutorTest { val snapshot = executor.stateSnapshot(userId) - assertThat(snapshot).startsWith("Before they can work:") + assertThat(snapshot).startsWith("Onboarding path:") assertThat(snapshot.indexOf("Before they can work:")) + .isGreaterThan(0) .isLessThan(snapshot.indexOf("Progress:")) } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsServiceTest.kt index 24f88fac..826b50f3 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingMetricsServiceTest.kt @@ -32,13 +32,6 @@ class OnboardingMetricsServiceTest { private val artifactIngestionApi: ArtifactIngestionApi = mockk() private val userGoalRepository: UserGoalRepository = mockk() - // Task-0 assignment is exercised in TaskZeroServiceTest; here it defaults to "none assigned". - private val taskZeroService: TaskZeroService = mockk(relaxed = true) - - // Autonomy is exercised in RampServiceTest; here it defaults to "not reached". This read must - // never write -- a PM opening the dashboard cannot be what grants somebody autonomy. - private val rampService: RampService = mockk(relaxed = true) - // Engineering unless a test says otherwise: these assert numbers, and the track only decides // the words around them -- except in the stall tests, where it decides whether a hire whose // work nothing observes can be seen at all. @@ -53,8 +46,6 @@ class OnboardingMetricsServiceTest { // that swapping pull requests for contributions did not move any of them. ContributionService(listOf(PullRequestEvidenceProvider(artifactIngestionApi))), userGoalRepository, - taskZeroService, - rampService, Clock.fixed(now, ZoneOffset.UTC), ) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ProjectAttentionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ProjectAttentionServiceTest.kt index 3982f25a..5577735e 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ProjectAttentionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ProjectAttentionServiceTest.kt @@ -40,7 +40,6 @@ class ProjectAttentionServiceTest { displayName = "A Hire", githubLogin = "hire", joinedAt = null, - taskZeroAssignedAt = null, firstTaskClaimedAt = null, firstContributionOpenedAt = null, firstResponseAt = null, @@ -52,7 +51,6 @@ class ProjectAttentionServiceTest { longestOpenWaitHours = longestOpenWaitHours, stalled = stalled, stalledReason = stalledReason, - autonomyReachedAt = null, returnedContributionCount = 0, ) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampServiceTest.kt deleted file mode 100644 index 6f35ba46..00000000 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/RampServiceTest.kt +++ /dev/null @@ -1,381 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.service - -import com.sprintstart.sprintstartbackend.ingestion.external.ArtifactIngestionApi -import com.sprintstart.sprintstartbackend.ingestion.external.model.dto.AuthoredPullRequest -import com.sprintstart.sprintstartbackend.onboarding.external.enums.CompetencyKind -import com.sprintstart.sprintstartbackend.onboarding.external.enums.CompetencySource -import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus -import com.sprintstart.sprintstartbackend.onboarding.external.enums.RampStage -import com.sprintstart.sprintstartbackend.onboarding.model.entity.AutonomyMilestone -import com.sprintstart.sprintstartbackend.onboarding.model.entity.Competency -import com.sprintstart.sprintstartbackend.onboarding.model.entity.KnowledgeRequest -import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTaskProposal -import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskZeroAssignment -import com.sprintstart.sprintstartbackend.onboarding.model.entity.UserCompetencyState -import com.sprintstart.sprintstartbackend.onboarding.model.entity.UserGoal -import com.sprintstart.sprintstartbackend.onboarding.repository.AutonomyMilestoneRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.CompetencyRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.KnowledgeRequestRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.UserCompetencyStateRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.UserGoalRepository -import com.sprintstart.sprintstartbackend.onboarding.service.evidence.PullRequestEvidenceProvider -import com.sprintstart.sprintstartbackend.user.external.ProjectMember -import com.sprintstart.sprintstartbackend.user.external.ProjectMembershipApi -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.web.server.ResponseStatusException -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.time.ZoneOffset -import java.util.Optional -import java.util.UUID -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class RampServiceTest { - private val taskZeroAssignmentRepository: TaskZeroAssignmentRepository = mockk(relaxed = true) - private val userGoalRepository: UserGoalRepository = mockk() - private val proposalRepository: StarterWorkTaskProposalRepository = mockk(relaxed = true) - private val userCompetencyStateRepository: UserCompetencyStateRepository = mockk(relaxed = true) - private val competencyRepository: CompetencyRepository = mockk() - private val autonomyMilestoneRepository: AutonomyMilestoneRepository = mockk(relaxed = true) - private val knowledgeRequestRepository: KnowledgeRequestRepository = mockk() - private val projectMembershipApi: ProjectMembershipApi = mockk() - private val artifactIngestionApi: ArtifactIngestionApi = mockk() - - private val now: Instant = Instant.parse("2026-07-21T12:00:00Z") - private val hireId: UUID = UUID.randomUUID() - private val projectId: UUID = UUID.randomUUID() - - private val service = RampService( - taskZeroAssignmentRepository, - userGoalRepository, - proposalRepository, - userCompetencyStateRepository, - competencyRepository, - autonomyMilestoneRepository, - knowledgeRequestRepository, - projectMembershipApi, - // A real ContributionService over the same mocked ingestion API, not a mock: these - // tests assert the numbers this service reports, and the point of the refactor is - // that swapping pull requests for contributions did not move any of them. - ContributionService(listOf(PullRequestEvidenceProvider(artifactIngestionApi))), - // The real reader over the same mocked repositories: "which task is this person on" is one - // answer shared with the board, and these tests are what pin it. - CurrentTaskReader(userGoalRepository, taskZeroAssignmentRepository, proposalRepository), - Clock.fixed(now, ZoneOffset.UTC), - ) - - @BeforeEach - fun `no proposals unless a test says so`() { - // A relaxed mock would hand back a relaxed Optional, whose orElse(null) is an Object. - every { proposalRepository.findById(any()) } returns Optional.empty() - } - - private fun daysAgo(days: Long): Instant = now.minus(Duration.ofDays(days)) - - private fun isMember(login: String? = "hire") { - every { projectMembershipApi.getProjectMembers(projectId) } returns - listOf(ProjectMember(hireId, "A Hire", login, daysAgo(20))) - } - - private fun pullRequest( - opened: Instant = daysAgo(5), - merged: Instant? = daysAgo(3), - changesRequested: Int = 0, - ) = AuthoredPullRequest( - artifactId = UUID.randomUUID(), - openedAt = opened, - firstResponseAt = opened.plus(Duration.ofHours(2)), - mergedAt = merged, - state = if (merged != null) "MERGED" else "OPEN", - changesRequestedCount = changesRequested, - ) - - private fun pullRequests(vararg prs: AuthoredPullRequest) { - every { artifactIngestionApi.getAuthoredPullRequests(projectId, "hire") } returns prs.toList() - } - - private fun noGoal() { - every { userGoalRepository.findByUserIdAndProjectId(hireId, projectId) } returns null - } - - private fun noEscalations() { - every { knowledgeRequestRepository.findAllByHireIdAndProjectId(hireId, projectId) } returns - emptyList() - } - - private fun noMilestone() { - every { autonomyMilestoneRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - every { autonomyMilestoneRepository.save(any()) } answers { firstArg() } - } - - private fun claimedGoal(vararg keys: String, claimedAt: Instant = daysAgo(6)): StarterWorkTaskProposal { - val proposal = StarterWorkTaskProposal( - sourceId = "github:acme/api:ISSUE:1", - title = "A real change", - competencyKeys = keys.toMutableList(), - status = ProposalStatus.LIVE, - ) - every { userGoalRepository.findByUserIdAndProjectId(hireId, projectId) } returns - UserGoal( - userId = hireId, - projectId = projectId, - sourceProposalId = proposal.id, - claimedAt = claimedAt, - ) - every { proposalRepository.findById(proposal.id) } returns Optional.of(proposal) - every { competencyRepository.findAllByKeyIn(keys.toList()) } returns - keys.map { Competency(key = it, label = it, kind = CompetencyKind.SKILL, targetLevel = 2) } - return proposal - } - - // --- credit ----------------------------------------------------------------------------- - - @Test - fun `a merged pull request credits the claimed task's competencies as VERIFIED`() { - isMember() - pullRequests(pullRequest()) - claimedGoal("kotlin") - noEscalations() - noMilestone() - every { userCompetencyStateRepository.findByUserIdAndCompetencyKey(hireId, "kotlin") } returns null - val saved = slot() - every { userCompetencyStateRepository.save(capture(saved)) } answers { firstArg() } - - val result = service.getForHire(hireId, projectId) - - assertEquals(listOf("kotlin"), result.creditedCompetencyKeys) - assertEquals(CompetencySource.VERIFIED, saved.captured.source) - // Credit lands at the bar the project set for that competency. - assertEquals(2, saved.captured.level) - } - - @Test - fun `Task 0 credits nothing`() { - isMember() - pullRequests(pullRequest()) - // Task 0 is an assignment, not a claimed goal -- so there is no path that could credit it. - noGoal() - every { taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns - TaskZeroAssignment(hireId = hireId, projectId = projectId, proposalId = UUID.randomUUID(), assignedAt = now) - noEscalations() - noMilestone() - - val result = service.getForHire(hireId, projectId) - - assertEquals(emptyList(), result.creditedCompetencyKeys) - verify(exactly = 0) { userCompetencyStateRepository.save(any()) } - } - - @Test - fun `credit never lowers a level already earned`() { - isMember() - pullRequests(pullRequest()) - claimedGoal("kotlin") - noEscalations() - noMilestone() - val existing = UserCompetencyState( - userId = hireId, - competencyKey = "kotlin", - level = 4, - source = CompetencySource.VERIFIED, - ) - every { userCompetencyStateRepository.findByUserIdAndCompetencyKey(hireId, "kotlin") } returns existing - - service.getForHire(hireId, projectId) - - assertEquals(4, existing.level) - } - - @Test - fun `a pull request merged before the task was claimed does not credit it`() { - isMember() - pullRequests(pullRequest(opened = daysAgo(12), merged = daysAgo(10))) - claimedGoal("kotlin", claimedAt = daysAgo(6)) - noEscalations() - noMilestone() - - val result = service.getForHire(hireId, projectId) - - assertEquals(emptyList(), result.creditedCompetencyKeys) - } - - // --- stage ------------------------------------------------------------------------------ - - @Test - fun `stage is counted in merged changes, and an open pull request has proven nothing`() { - isMember() - pullRequests(pullRequest(merged = null)) - noGoal() - every { taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - noEscalations() - noMilestone() - - val result = service.getForHire(hireId, projectId) - - assertEquals(RampStage.TASK_ZERO, result.stage) - assertEquals(0, result.mergedCount) - } - - @Test - fun `one merge is task one, two is task two plus`() { - isMember() - noGoal() - every { taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - noEscalations() - - every { autonomyMilestoneRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - every { autonomyMilestoneRepository.save(any()) } answers { firstArg() } - pullRequests(pullRequest(changesRequested = 1)) - assertEquals(RampStage.TASK_ONE, service.getForHire(hireId, projectId).stage) - - pullRequests( - pullRequest(opened = daysAgo(9), merged = daysAgo(8), changesRequested = 1), - pullRequest(changesRequested = 1), - ) - assertEquals(RampStage.TASK_TWO_PLUS, service.getForHire(hireId, projectId).stage) - } - - // --- autonomy --------------------------------------------------------------------------- - - @Test - fun `autonomy is not granted when the last change needed rework`() { - isMember() - pullRequests(pullRequest(changesRequested = 2)) - noGoal() - noEscalations() - noMilestone() - - val result = service.getForHire(hireId, projectId) - - assertFalse(result.autonomy.reached) - assertTrue(result.autonomy.blockers.any { it.contains("sent back") }) - verify(exactly = 0) { autonomyMilestoneRepository.save(any()) } - } - - @Test - fun `autonomy is not granted when a person was pulled in during that change`() { - isMember() - pullRequests(pullRequest(opened = daysAgo(5), merged = daysAgo(3))) - noGoal() - every { knowledgeRequestRepository.findAllByHireIdAndProjectId(hireId, projectId) } returns - listOf( - KnowledgeRequest( - projectId = projectId, - hireId = hireId, - question = "How do we run this locally?", - createdAt = daysAgo(4), - ), - ) - noMilestone() - - val result = service.getForHire(hireId, projectId) - - assertFalse(result.autonomy.reached) - assertTrue(result.autonomy.blockers.any { it.contains("pulled in a person") }) - } - - @Test - fun `help given before the change started does not block autonomy`() { - isMember() - pullRequests(pullRequest(opened = daysAgo(5), merged = daysAgo(3))) - noGoal() - // Week one needed help; this change did not. That is exactly what the exit asks about. - every { knowledgeRequestRepository.findAllByHireIdAndProjectId(hireId, projectId) } returns - listOf( - KnowledgeRequest( - projectId = projectId, - hireId = hireId, - question = "Where does the config live?", - createdAt = daysAgo(15), - ), - ) - noMilestone() - - assertTrue(service.getForHire(hireId, projectId).autonomy.reached) - } - - @Test - fun `reaching autonomy records the merge time, not the moment it was noticed`() { - isMember() - val mergedAt = daysAgo(3) - pullRequests(pullRequest(merged = mergedAt)) - noGoal() - noEscalations() - val saved = slot() - every { autonomyMilestoneRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - every { autonomyMilestoneRepository.save(capture(saved)) } answers { firstArg() } - - val result = service.getForHire(hireId, projectId) - - assertTrue(result.autonomy.reached) - assertEquals(mergedAt, saved.captured.reachedAt) - assertEquals(RampStage.AUTONOMOUS, result.stage) - assertNotNull(result.autonomy.provenByArtifactId) - } - - @Test - fun `autonomy once reached is never re-evaluated`() { - isMember() - // Later work needed rework -- which is ordinary, and does not un-happen the milestone. - pullRequests(pullRequest(changesRequested = 3)) - noGoal() - every { autonomyMilestoneRepository.findByHireIdAndProjectId(hireId, projectId) } returns - AutonomyMilestone(hireId = hireId, projectId = projectId, reachedAt = daysAgo(10)) - - val result = service.getForHire(hireId, projectId) - - assertTrue(result.autonomy.reached) - assertEquals(daysAgo(10), result.autonomy.reachedAt) - assertEquals(emptyList(), result.autonomy.blockers) - } - - @Test - fun `no merged change yet is a blocker stated plainly, not a silent false`() { - isMember() - pullRequests() - noGoal() - every { taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - noEscalations() - noMilestone() - - val result = service.getForHire(hireId, projectId) - - assertFalse(result.autonomy.reached) - assertNull(result.autonomy.reachedAt) - assertEquals(listOf("No merged change here yet"), result.autonomy.blockers) - } - - @Test - fun `a hire with no declared GitHub login is not reported as having shipped nothing wrongly`() { - isMember(login = null) - noGoal() - every { taskZeroAssignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - noEscalations() - noMilestone() - - val result = service.getForHire(hireId, projectId) - - assertEquals(0, result.mergedCount) - assertFalse(result.autonomy.reached) - } - - @Test - fun `404s when the hire is not a member`() { - every { projectMembershipApi.getProjectMembers(projectId) } returns emptyList() - - assertThrows { service.getForHire(hireId, projectId) } - } -} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationServiceTest.kt index c90dee85..a1cf0934 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskOrientationServiceTest.kt @@ -17,14 +17,12 @@ import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTas import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskOrientationCitation import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskOrientationPacket import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskOrientationSection -import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskZeroAssignment import com.sprintstart.sprintstartbackend.onboarding.model.exceptions.OnboardingAiException import com.sprintstart.sprintstartbackend.onboarding.model.request.orientation.AuthorOrientationCitationRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.orientation.AuthorOrientationRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.orientation.AuthorOrientationSectionRequest import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository import com.sprintstart.sprintstartbackend.onboarding.repository.TaskOrientationPacketRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository import com.sprintstart.sprintstartbackend.user.external.ProjectMember import com.sprintstart.sprintstartbackend.user.external.ProjectMembershipApi import io.mockk.coEvery @@ -57,7 +55,7 @@ import kotlin.test.assertTrue class TaskOrientationServiceTest { private val packetRepository: TaskOrientationPacketRepository = mockk(relaxed = true) - private val assignmentRepository: TaskZeroAssignmentRepository = mockk(relaxed = true) + private val currentTaskReader: CurrentTaskReader = mockk() private val proposalRepository: StarterWorkTaskProposalRepository = mockk(relaxed = true) private val projectMembershipApi: ProjectMembershipApi = mockk() private val artifactIngestionApi: ArtifactIngestionApi = mockk() @@ -71,7 +69,7 @@ class TaskOrientationServiceTest { private val service = TaskOrientationService( packetRepository, - assignmentRepository, + currentTaskReader, proposalRepository, projectMembershipApi, artifactIngestionApi, @@ -87,7 +85,6 @@ class TaskOrientationServiceTest { summary = "The header is computed once at boot.", sourceUrl = "https://github.com/org/repo/issues/7", status = ProposalStatus.LIVE, - taskZeroEligible = true, ) private fun isMember() { @@ -97,8 +94,7 @@ class TaskOrientationServiceTest { private fun hasTask(cached: TaskOrientationPacket? = null) { isMember() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns - TaskZeroAssignment(hireId = hireId, projectId = projectId, proposalId = proposal.id, assignedAt = now) + every { currentTaskReader.currentTaskFor(hireId, projectId) } returns proposal every { proposalRepository.findById(proposal.id) } returns Optional.of(proposal) every { artifactIngestionApi.getTaskSource(proposal.sourceId) } returns null every { packetRepository.findByTaskProposalIdAndProjectId(proposal.id, projectId) } returns cached @@ -267,7 +263,7 @@ class TaskOrientationServiceTest { @Test fun `no current task is a handled state and calls no AI`() = runTest { isMember() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null + every { currentTaskReader.currentTaskFor(hireId, projectId) } returns null val result = service.getForHire(hireId, projectId) @@ -315,17 +311,6 @@ class TaskOrientationServiceTest { assertEquals("The header is computed once at boot.", body.captured) } - @Test - fun `reading orientation never assigns a task`() = runTest { - hasTask() - coEvery { onboardingAiClient.assembleOrientation(any(), any(), any(), any(), any()) } returns - assembled(section("SET_UP")) - - service.getForHire(hireId, projectId) - - verify(exactly = 0) { assignmentRepository.save(any()) } - } - @Test fun `404s when the hire is not a member of the project`() = runTest { every { projectMembershipApi.getProjectMembers(projectId) } returns emptyList() @@ -526,7 +511,7 @@ class TaskOrientationServiceTest { @Test fun `authorForHire 404s when the hire has no current task`() = runTest { isMember() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null + every { currentTaskReader.currentTaskFor(hireId, projectId) } returns null val error = assertThrows { service.authorForHire(hireId, projectId, authorRequest()) @@ -618,7 +603,7 @@ class TaskOrientationServiceTest { @Test fun `no current task streams a single done and calls no AI`() = runTest { isMember() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null + every { currentTaskReader.currentTaskFor(hireId, projectId) } returns null val events = service.streamForHire(hireId, projectId).toList() diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroServiceTest.kt deleted file mode 100644 index 6dba1259..00000000 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TaskZeroServiceTest.kt +++ /dev/null @@ -1,202 +0,0 @@ -package com.sprintstart.sprintstartbackend.onboarding.service - -import com.sprintstart.sprintstartbackend.ingestion.external.ArtifactIngestionApi -import com.sprintstart.sprintstartbackend.ingestion.external.model.dto.AuthoredPullRequest -import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus -import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTaskProposal -import com.sprintstart.sprintstartbackend.onboarding.model.entity.TaskZeroAssignment -import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository -import com.sprintstart.sprintstartbackend.onboarding.repository.TaskZeroAssignmentRepository -import com.sprintstart.sprintstartbackend.onboarding.service.evidence.PullRequestEvidenceProvider -import com.sprintstart.sprintstartbackend.user.external.ProjectMember -import com.sprintstart.sprintstartbackend.user.external.ProjectMembershipApi -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.web.server.ResponseStatusException -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.time.ZoneOffset -import java.util.Optional -import java.util.UUID -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class TaskZeroServiceTest { - private val proposalRepository: StarterWorkTaskProposalRepository = mockk(relaxed = true) - private val assignmentRepository: TaskZeroAssignmentRepository = mockk(relaxed = true) - private val projectMembershipApi: ProjectMembershipApi = mockk() - private val artifactIngestionApi: ArtifactIngestionApi = mockk() - - private val now: Instant = Instant.parse("2026-07-20T12:00:00Z") - private val hireId: UUID = UUID.randomUUID() - private val projectId: UUID = UUID.randomUUID() - - private val service = TaskZeroService( - proposalRepository, - assignmentRepository, - projectMembershipApi, - // A real ContributionService over the same mocked ingestion API, not a mock: these - // tests assert the numbers this service reports, and the point of the refactor is - // that swapping pull requests for contributions did not move any of them. - ContributionService(listOf(PullRequestEvidenceProvider(artifactIngestionApi))), - Clock.fixed(now, ZoneOffset.UTC), - ) - - private fun member(login: String? = "hire") = - ProjectMember(hireId, "A Hire", login, now.minus(Duration.ofDays(5))) - - private fun isMember(login: String? = "hire") { - every { projectMembershipApi.getProjectMembers(projectId) } returns listOf(member(login)) - } - - private fun noAuthoredPrs() { - every { artifactIngestionApi.getAuthoredPullRequests(projectId, "hire") } returns emptyList() - } - - private fun task(eligible: Boolean = true, createdDaysAgo: Long = 1) = - StarterWorkTaskProposal( - sourceId = "github:org/repo:ISSUE:${UUID.randomUUID()}", - title = "Fix a typo", - status = ProposalStatus.LIVE, - taskZeroEligible = eligible, - createdAt = now.minus(Duration.ofDays(createdDaysAgo)), - ) - - @Test - fun `setEligibility flags an approved task`() { - val proposal = task(eligible = false) - every { proposalRepository.findById(proposal.id) } returns Optional.of(proposal) - every { proposalRepository.save(any()) } answers { firstArg() } - - val result = service.setEligibility(proposal.id, true) - - assertTrue(result.taskZeroEligible) - } - - @Test - fun `setEligibility 409s on a task that is not approved`() { - val proposal = StarterWorkTaskProposal(sourceId = "s", title = "t", status = ProposalStatus.REJECTED) - every { proposalRepository.findById(proposal.id) } returns Optional.of(proposal) - - assertThrows { service.setEligibility(proposal.id, true) } - } - - @Test - fun `setEligibility 404s on an unknown task`() { - val id = UUID.randomUUID() - every { proposalRepository.findById(id) } returns Optional.empty() - - assertThrows { service.setEligibility(id, true) } - } - - @Test - fun `getForHire auto-assigns the earliest eligible task on first read`() { - isMember() - noAuthoredPrs() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - every { assignmentRepository.findAllAssignedProposalIds() } returns emptyList() - val older = task(createdDaysAgo = 5) - val newer = task(createdDaysAgo = 1) - every { proposalRepository.findAllByStatusAndTaskZeroEligibleTrue(ProposalStatus.LIVE) } returns - listOf(newer, older) - val saved = slot() - every { assignmentRepository.save(capture(saved)) } answers { firstArg() } - - val result = service.getForHire(hireId, projectId) - - assertFalse(result.noneAvailable) - // The oldest eligible task is handed out first. - assertEquals(older.id, saved.captured.proposalId) - } - - @Test - fun `getForHire is a handled state, not an error, when nothing is eligible`() { - isMember() - noAuthoredPrs() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - every { assignmentRepository.findAllAssignedProposalIds() } returns emptyList() - every { proposalRepository.findAllByStatusAndTaskZeroEligibleTrue(ProposalStatus.LIVE) } returns emptyList() - - val result = service.getForHire(hireId, projectId) - - assertTrue(result.noneAvailable) - assertNull(result.task) - verify(exactly = 0) { assignmentRepository.save(any()) } - } - - @Test - fun `getForHire never assigns the same task to two hires`() { - isMember() - noAuthoredPrs() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - val taken = task(createdDaysAgo = 5) - val free = task(createdDaysAgo = 1) - every { assignmentRepository.findAllAssignedProposalIds() } returns listOf(taken.id) - every { proposalRepository.findAllByStatusAndTaskZeroEligibleTrue(ProposalStatus.LIVE) } returns - listOf(taken, free) - val saved = slot() - every { assignmentRepository.save(capture(saved)) } answers { firstArg() } - - service.getForHire(hireId, projectId) - - assertEquals(free.id, saved.captured.proposalId) - } - - @Test - fun `getForHire returns the existing assignment without assigning again`() { - isMember() - noAuthoredPrs() - val proposal = task() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns - TaskZeroAssignment(hireId = hireId, projectId = projectId, proposalId = proposal.id, assignedAt = now) - every { proposalRepository.findById(proposal.id) } returns Optional.of(proposal) - - val result = service.getForHire(hireId, projectId) - - assertEquals(now, result.assignedAt) - verify(exactly = 0) { assignmentRepository.save(any()) } - } - - @Test - fun `getForHire reports the loop proven once the hire has merged a pull request`() { - isMember() - every { assignmentRepository.findByHireIdAndProjectId(hireId, projectId) } returns null - every { assignmentRepository.findAllAssignedProposalIds() } returns emptyList() - every { proposalRepository.findAllByStatusAndTaskZeroEligibleTrue(ProposalStatus.LIVE) } returns emptyList() - every { artifactIngestionApi.getAuthoredPullRequests(projectId, "hire") } returns - listOf( - AuthoredPullRequest( - UUID.randomUUID(), - now.minus(Duration.ofDays(2)), - null, - now.minus(Duration.ofDays(1)), - "MERGED", - ), - ) - - val result = service.getForHire(hireId, projectId) - - assertTrue(result.loopProven) - } - - @Test - fun `getForHire 404s when the hire is not a member`() { - every { projectMembershipApi.getProjectMembers(projectId) } returns emptyList() - - assertThrows { service.getForHire(hireId, projectId) } - } - - @Test - fun `unassign frees the task`() { - service.unassign(hireId, projectId) - - verify(exactly = 1) { assignmentRepository.deleteByHireIdAndProjectId(hireId, projectId) } - } -} From a68d1d0738ce6cd570fdc9086b80bb9761d4a487 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 21 Sep 2026 11:43:41 +0200 Subject: [PATCH 15/22] Restore the PM's Task-0 flag as a label on the pool #311 removed Task 0 whole. That was right about the half that mattered: a first task handed out automatically, with the hire's path nowhere in it, was a second onboarding competing with the one their PM's blueprint prescribes. It was wrong about the other half. While that work was in flight the pool UI grew around the flag -- a "Task 0" filter, a badge on the card and a toggle in the drawer -- and removing the field and the endpoint under it left a PM clicking a switch that 404s. So the flag comes back as what it now is: a PM's note that a task is small and safe enough to start on. Nothing assigns a flagged task to anybody, nothing withholds an unflagged one, and hires still claim their own work from the whole live pool. The assignment, the ramp and the autonomy milestone stay gone. Co-Authored-By: Claude Opus 5 --- .../controller/StarterWorkController.kt | 31 ++++++++++++++++ .../model/entity/StarterWorkTaskProposal.kt | 15 ++++---- .../mapper/StarterWorkTaskProposalMapper.kt | 1 + .../SetTaskZeroEligibilityRequest.kt | 11 ++++++ .../starterwork/StarterWorkResponses.kt | 5 +++ .../service/StarterWorkTaskProposalService.kt | 20 +++++++++++ .../V19__retire_legacy_onboarding.sql | 7 ++-- .../controller/StarterWorkControllerTest.kt | 26 ++++++++++++++ .../onboarding/service/BoardServiceTest.kt | 1 + .../service/BuddyToolExecutorTest.kt | 1 + .../StarterWorkTaskProposalServiceTest.kt | 36 +++++++++++++++++++ 11 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkController.kt index e3b3b08a..4330c8bb 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkController.kt @@ -6,6 +6,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.request.competency.Re import com.sprintstart.sprintstartbackend.onboarding.model.request.starterwork.ClaimGoalRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.starterwork.CreateStarterWorkTaskRequest import com.sprintstart.sprintstartbackend.onboarding.model.request.starterwork.PromoteStarterWorkCandidateRequest +import com.sprintstart.sprintstartbackend.onboarding.model.request.starterwork.SetTaskZeroEligibilityRequest import com.sprintstart.sprintstartbackend.onboarding.model.response.goal.GoalView import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.GenerateStarterWorkResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.RankedStarterWorkTaskResponse @@ -313,6 +314,36 @@ class StarterWorkController( @PathVariable id: UUID, ): StarterWorkTaskProposalResponse = starterWorkTaskProposalService.markReviewed(id) + /** + * Flags a live task as a good first one for somebody. + * + * A note on the task, not an assignment: the pool shows it so a PM can see at a glance which + * tasks they consider gentle starts. Nothing hands a flagged task to a hire and nothing + * withholds an unflagged one -- hires claim their own work. + */ + @Operation( + summary = "Flag a starter-work task as a good first one (Task 0)", + description = "A PM's judgement that this live task is small and safe enough to be somebody's " + + "first one. A label the pool shows, never a gate or an assignment.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Flag updated"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse(responseCode = "403", description = "Insufficient role"), + ApiResponse(responseCode = "404", description = "No task found with the given id"), + ApiResponse(responseCode = "409", description = "The task is no longer in the pool"), + ], + ) + @ResponseStatus(HttpStatus.OK) + @PostMapping("/{id}/task-zero") + @PreAuthorize("hasAnyRole('ADMIN', 'PM')") + fun setTaskZeroEligibility( + @Parameter(description = "UUID of the starter-work task to flag") + @PathVariable id: UUID, + @RequestBody request: SetTaskZeroEligibilityRequest, + ): StarterWorkTaskProposalResponse = starterWorkTaskProposalService.setTaskZeroEligibility(id, request.eligible) + /** * Takes a starter-work task out of the pool for good. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt index 6def447e..6882e1d0 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/StarterWorkTaskProposal.kt @@ -67,13 +67,16 @@ class StarterWorkTaskProposal( @Column(nullable = false) var reviewed: Boolean = false, /** - * Retired: Task 0, the first task a hire was handed automatically, is gone -- onboarding is the - * path their PM's blueprint prescribes (#311). Nothing reads or writes this any more. + * A PM's judgement that this task is small and safe enough to be somebody's first one. * - * Still mapped only because databases created before then hold it as a NOT NULL column with no - * default, and `ddl-auto: update` never drops a column: without the field every new proposal - * would fail to insert. `V19__retire_legacy_onboarding.sql` gives the column a default; once that - * has run everywhere, delete this field and drop the column. + * A fact about the *task*, not about anybody's onboarding. That distinction is the whole of + * what #311 changed here: the flag used to feed an assignment that handed a hire their first + * task and called it onboarding, and onboarding is now the path their PM's blueprint + * prescribes. The assignment is gone; the judgement is worth keeping, because "this one is + * safe to start on" is a useful thing for a PM to record and for the pool to show. + * + * Nothing withholds an unflagged task from anybody: hires claim their own work from the whole + * live pool. */ @Column(name = "task_zero_eligible", nullable = false) var taskZeroEligible: Boolean = false, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt index fe36c339..a2c066cc 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/mapper/StarterWorkTaskProposalMapper.kt @@ -13,6 +13,7 @@ fun StarterWorkTaskProposal.toResponse(): StarterWorkTaskProposalResponse = sourceUrl = sourceUrl, competencyKeys = competencyKeys.toList(), status = status, + taskZeroEligible = taskZeroEligible, reviewed = reviewed, sourceHasAssignee = sourceHasAssignee, sourceCheckedAt = sourceCheckedAt, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt new file mode 100644 index 00000000..9453a69d --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/starterwork/SetTaskZeroEligibilityRequest.kt @@ -0,0 +1,11 @@ +package com.sprintstart.sprintstartbackend.onboarding.model.request.starterwork + +/** + * A PM's decision on whether a live starter-work task is a good first one for somebody ("Task 0"). + * + * A label on the task, not an assignment: flagging one hands it to nobody, and leaving one + * unflagged keeps it from nobody. Hires claim their own work from the whole live pool. + */ +data class SetTaskZeroEligibilityRequest( + val eligible: Boolean, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt index b3b2a488..635630fd 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/starterwork/StarterWorkResponses.kt @@ -15,6 +15,11 @@ data class StarterWorkTaskProposalResponse( val sourceUrl: String?, val competencyKeys: List, val status: ProposalStatus, + /** + * Whether a PM flagged this task as a good first one; see + * `StarterWorkTaskProposal.taskZeroEligible`. A hint the pool shows, never a gate. + */ + val taskZeroEligible: Boolean, /** Whether a person has actually looked at this task; see `StarterWorkTaskProposal.reviewed`. */ val reviewed: Boolean, /** diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalService.kt index 35b454fa..2fac9cc2 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalService.kt @@ -237,6 +237,26 @@ class StarterWorkTaskProposalService( return proposal.toResponse() } + /** + * Records a PM's judgement that this task is a good first one for somebody ("Task 0"). + * + * The flag rides on the pool entry and nothing acts on it: the pool filters and badges by it so + * a PM can see which tasks they vouched for as gentle starts, and that is the whole of it. + * Nothing assigns a flagged task to anybody -- #311 left the path their PM's blueprint + * prescribes as the one thing that onboards a hire, and picking up work is a hire's own move. + * Idempotent. + * + * @throws ResponseStatusException 404 if no task matches [id]; 409 if it is no longer live -- + * vouching for a task nobody can claim is a judgement about nothing. + */ + @Transactional + fun setTaskZeroEligibility(id: UUID, eligible: Boolean): StarterWorkTaskProposalResponse { + val proposal = findLiveProposal(id) + proposal.taskZeroEligible = eligible + proposal.decidedAt = Instant.now() + return proposal.toResponse() + } + /** * Creates a hand-authored starter-work task, with no AI mining in the loop. * diff --git a/src/main/resources/db/migration/V19__retire_legacy_onboarding.sql b/src/main/resources/db/migration/V19__retire_legacy_onboarding.sql index c8f27811..b78fe0fe 100644 --- a/src/main/resources/db/migration/V19__retire_legacy_onboarding.sql +++ b/src/main/resources/db/migration/V19__retire_legacy_onboarding.sql @@ -6,9 +6,10 @@ DROP TABLE IF EXISTS task_zero_assignments; DROP TABLE IF EXISTS autonomy_milestones; --- task_zero_eligible stays for now: StarterWorkTaskProposal still maps it, because an existing --- database holds it as NOT NULL with no default and inserts would fail without the field. The --- default below lifts that; once it has run everywhere, delete the field and drop the column. +-- task_zero_eligible stays, and keeps its meaning: a PM's note that a task is a good first one. +-- What went is the assignment that read it -- the flag is a label on the pool entry now, and +-- nothing hands a flagged task to anybody. The default is for rows written before the column had +-- one. ALTER TABLE starter_work_task_proposals ALTER COLUMN task_zero_eligible SET DEFAULT false; diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt index 53eb902c..c2d27437 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/StarterWorkControllerTest.kt @@ -95,6 +95,7 @@ class StarterWorkControllerTest( sourceUrl = "https://github.com/org/repo/issues/1", competencyKeys = listOf("docs"), status = ProposalStatus.LIVE, + taskZeroEligible = false, reviewed = true, sourceHasAssignee = null, sourceCheckedAt = null, @@ -193,6 +194,31 @@ class StarterWorkControllerTest( ).andExpect(status().isForbidden) } + @Test + fun `setTaskZeroEligibility should return 200 and the flagged task for a PM`() { + val id = UUID.randomUUID() + every { starterWorkTaskProposalService.setTaskZeroEligibility(id, true) } returns taskResponse() + + mockMvc + .perform( + post("/api/v1/onboarding/starter-work/$id/task-zero") + .with(pmJwt) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("eligible" to true))), + ).andExpect(status().isOk) + } + + @Test + fun `setTaskZeroEligibility should return 403 for a plain USER`() { + mockMvc + .perform( + post("/api/v1/onboarding/starter-work/${UUID.randomUUID()}/task-zero") + .with(userJwt) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("eligible" to true))), + ).andExpect(status().isForbidden) + } + @Test fun `listCandidates should return 200 with the pool marking, for a PM`() { val projectId = UUID.randomUUID() diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt index 88f17307..2ca92461 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt @@ -752,6 +752,7 @@ class BoardServiceTest { sourceUrl = null, competencyKeys = emptyList(), status = ProposalStatus.LIVE, + taskZeroEligible = false, reviewed = true, sourceHasAssignee = null, sourceCheckedAt = null, diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt index 0d3a5ad7..a1799d38 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt @@ -209,6 +209,7 @@ class BuddyToolExecutorTest { sourceUrl = sourceUrl, competencyKeys = emptyList(), status = ProposalStatus.LIVE, + taskZeroEligible = false, reviewed = true, sourceHasAssignee = null, sourceCheckedAt = null, diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalServiceTest.kt index 4b06b005..c64a1e12 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/StarterWorkTaskProposalServiceTest.kt @@ -45,6 +45,7 @@ import java.util.Optional import java.util.UUID import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -318,6 +319,41 @@ class StarterWorkTaskProposalServiceTest { } } + @Nested + inner class TaskZeroFlag { + @Test + fun `flags a live task and clears the flag again`() { + val proposal = StarterWorkTaskProposal(sourceId = "s1", title = "Fix the typo") + every { starterWorkTaskProposalRepository.findById(proposal.id) } returns Optional.of(proposal) + + assertTrue(service.setTaskZeroEligibility(proposal.id, eligible = true).taskZeroEligible) + assertFalse(service.setTaskZeroEligibility(proposal.id, eligible = false).taskZeroEligible) + } + + @Test + fun `throws 404 when no proposal matches`() { + val id = UUID.randomUUID() + every { starterWorkTaskProposalRepository.findById(id) } returns Optional.empty() + + val ex = assertThrows { service.setTaskZeroEligibility(id, eligible = true) } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `refuses a task that left the pool`() { + // Vouching for a task nobody can claim is a judgement about nothing. + val proposal = StarterWorkTaskProposal(sourceId = "s1", title = "t1", status = ProposalStatus.STALE) + every { starterWorkTaskProposalRepository.findById(proposal.id) } returns Optional.of(proposal) + + val ex = assertThrows { + service.setTaskZeroEligibility(proposal.id, eligible = true) + } + + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + } + } + @Nested inner class CreateTask { @Test From d4da30f4488877648cef03e6cb42853c74bdbd86 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Mon, 21 Sep 2026 11:55:05 +0200 Subject: [PATCH 16/22] Point the work chip at the pool, not at the path "What should I work on?" sat next to "Where am I on my path?" and the two were a coin toss. The mentor read it as the path's question, which is how a hire with work waiting in the pool got told there was nothing for them. The chip now asks for the pool and nothing else, and the tool's own description says the pool is open however far along the path somebody is -- the same rule the persona holds, stated where the reasoner reads it. Co-Authored-By: Claude Opus 5 --- .../onboarding/service/BuddySuggestionService.kt | 7 +++++-- .../onboarding/service/BuddyToolExecutor.kt | 9 ++++++--- .../onboarding/controller/BuddyControllerTest.kt | 9 ++++++--- .../onboarding/service/BuddySuggestionServiceTest.kt | 6 +++--- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt index ca9a3cc2..a75a90f4 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt @@ -74,9 +74,12 @@ class BuddySuggestionService( label = "What do I still need?", question = "What do I still need to get set up?", ), + // "What should I work on?" was a coin toss next to the path chip, and the mentor + // read it as the path's question -- which is how a hire with work sitting in the pool + // got told there was nothing for them. This one asks for the pool and nothing else. BuddyToolExecutor.GET_SUGGESTED_TASKS to BuddySuggestionResponse( - label = "What should I work on?", - question = "What should I work on next?", + label = "Anything I can pick up?", + question = "Is there something in the work pool I could pick up?", ), BuddyToolExecutor.GET_MY_OPEN_PULL_REQUESTS to BuddySuggestionResponse( label = "Is my PR stuck?", diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt index 2877ce30..f328b4b3 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt @@ -521,9 +521,12 @@ class BuddyToolExecutor( name = GET_SUGGESTED_TASKS, description = "Good next starter-work tasks for the hire, ranked by fit, each with the " + "plain reasons it was suggested and the task_id to claim it by. Use this for " + - "questions like 'what should I work on?' or 'what's a good first task for me?'. " + - "Present the reasons, never a score. When the hire picks one, offer to claim it " + - "as their goal with claim_goal. Takes no arguments — it always ranks for the caller.", + "questions like 'what should I work on?', 'what's a good first task for me?' and " + + "'is there anything I could pick up?'. The pool is open to them however far along " + + "their onboarding path they are, so never hold it back until they have got " + + "further. Present the reasons, never a score. When the hire picks one, offer to " + + "claim it as their goal with claim_goal. Takes no arguments -- it always ranks " + + "for the caller.", parameters = noArgs(), ) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt index b08a982d..6c256908 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt @@ -92,14 +92,17 @@ class BuddyControllerTest( @Test fun `getSuggestionsForMe should return 200 with the hire's chips`() { every { buddySuggestionService.forMe(authId) } returns listOf( - BuddySuggestionResponse(label = "What should I work on?", question = "What should I work on next?"), + BuddySuggestionResponse( + label = "Anything I can pick up?", + question = "Is there something in the work pool I could pick up?", + ), ) mockMvc .perform(get("/api/v1/onboarding/me/buddy/suggestions").with(userJwt)) .andExpect(status().isOk) - .andExpect(jsonPath("$[0].label").value("What should I work on?")) - .andExpect(jsonPath("$[0].question").value("What should I work on next?")) + .andExpect(jsonPath("$[0].label").value("Anything I can pick up?")) + .andExpect(jsonPath("$[0].question").value("Is there something in the work pool I could pick up?")) } /** diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt index c6132f68..263327e0 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt @@ -33,7 +33,7 @@ class BuddySuggestionServiceTest { ) assertThat(service.forHire(userId).map { it.label }) - .containsExactly("How's my work going?", "What should I work on?") + .containsExactly("How's my work going?", "Anything I can pick up?") } @Test @@ -115,7 +115,7 @@ class BuddySuggestionServiceTest { "Where am I on my path?", "What do I still need?", "How's my work going?", - "What should I work on?", + "Anything I can pick up?", ) } @@ -146,7 +146,7 @@ class BuddySuggestionServiceTest { every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) mounted(BuddyToolExecutor.GET_SUGGESTED_TASKS) - assertThat(service.forMe(authId).map { it.label }).containsExactly("What should I work on?") + assertThat(service.forMe(authId).map { it.label }).containsExactly("Anything I can pick up?") } @Test From 856445d414aaa2da368ab3078b25086758709663 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Thu, 24 Sep 2026 19:00:26 +0200 Subject: [PATCH 17/22] Stop the buddy reading a hire's path as a line of phases The path tool put the hire in "the first phase with anything open, by position". Blueprints open several phases at once, and the journey page lets the hire pick: a hire who finished phase 1 and chose phase 3 was told to start phase 2, and told again after correcting the buddy. Where they stand now follows the page's own rule (`resolveNextAction`): the phase they have started, most recently touched first; else the only open phase; else a choice between the open phases, each with where it starts. Other open phases are named as open alongside, "still left" includes lower-numbered phases, and the greeting says the same. A phase picked on the page but not started yet is not stored anywhere, so the choice text tells the buddy to take the hire's word for it. Refs #311 Co-Authored-By: Claude Opus 5.5 --- .../onboarding/service/BuddyPathTools.kt | 305 ++++++++++++------ .../onboarding/service/BuddyPathToolsTest.kt | 54 ++++ 2 files changed, 267 insertions(+), 92 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index 1bed1642..c78a5747 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -86,22 +86,79 @@ class BuddyPathTools( val phases = path.phases.sortedBy { it.position } if (phases.isEmpty()) return NO_PHASES - val currentIndex = phases.indexOfFirst { it.isOpen() }.takeIf { it >= 0 } ?: phases.lastIndex - - val current = phases[currentIndex] - val checklists = checklistsOf(current) + return when (val where = standingOf(phases)) { + // A path whose phases all came back empty was never started, let alone finished: say + // what is actually there, which is nothing, rather than congratulate them. + Standing.Finished -> if (phases.all { it.steps.isEmpty() && it.questions.isEmpty() }) { + buildString { + appendCurrentPhase(phases.last(), phases.lastIndex, phases) + appendEmptyPhases(path) + } + } else { + FINISHED_PATH.format(phases.size) + } + is Standing.Choosing -> buildString { + appendChoice(where.phases, phases) + appendEmptyPhases(path) + append(NEWLINE + CLOSING) + } + is Standing.In -> buildString { + val current = where.phase + val checklists = checklistsOf(current) + appendLine(standing(phases, current)) + appendLine() + appendCurrentPhase(current, phases.indexOf(current), phases) + appendReadyToClose(current, phases, checklists) + appendCurrentTasks(stepTheyAreOn(current), checklists) + appendNextItem(current, readyToClose(current, checklists).firstOrNull()) + appendAhead(phases, current) + appendEmptyPhases(path) + append(NEWLINE + CLOSING) + } + } + } - return buildString { - appendLine(standing(phases, currentIndex)) - appendLine() - appendCurrentPhase(current, currentIndex, phases) - appendReadyToClose(current, phases, checklists) - appendCurrentTasks(stepTheyAreOn(current), checklists) - appendNextItem(phases, readyToClose(current, checklists).firstOrNull()) - appendAhead(phases, currentIndex) - appendEmptyPhases(path) - append(NEWLINE + CLOSING) + /** + * Where the hire stands on a path whose phases run side by side. + * + * The rule the hire's own page follows (`resolveNextAction` in the frontend), so the mentor and + * the page cannot disagree about it: a phase they have already started, most recently touched + * first; else the only phase that is open; else a real choice between the open phases. Locked + * phases are never candidates. + * + * This used to be "the first phase with anything open, by position", which read a blueprint as a + * line: a hire who finished phase 1 and picked phase 3 on their page was told to start phase 2, + * and told it again when they said otherwise. + * + * A phase picked on the page but not started yet is invisible here -- the choice is page state, + * not stored anywhere -- which is why [Standing.Choosing] tells the mentor to take the hire's word + * for which phase they are doing. + */ + private fun standingOf(phases: List): Standing { + val candidates = phases.filter { !it.locked && nextItemIn(it) != null } + if (candidates.isEmpty()) { + // Nothing reachable: either done, or everything left is locked or waiting on a skip + // decision -- then the first unfinished phase is still what there is to talk about. + return phases.firstOrNull { it.isOpen() }?.let { Standing.In(it) } ?: Standing.Finished } + val started = candidates.filter { it.isStarted() }.maxByOrNull { it.lastActivity() } + val pick = started ?: candidates.singleOrNull() + return pick?.let { Standing.In(it) } ?: Standing.Choosing(candidates) + } + + /** The three answers to "where are they on their path". */ + private sealed interface Standing { + /** Working in one phase -- the one to describe in full. */ + data class In( + val phase: GetOnboardingPhaseForUserResponse, + ) : Standing + + /** Several phases open and none of them started: the hire picks. */ + data class Choosing( + val phases: List, + ) : Standing + + data object Finished : Standing } /** @@ -245,26 +302,33 @@ class BuddyPathTools( return "Onboarding path:\nTheir path has no phases in it, so there is nothing on it to do yet." } - val currentIndex = phases.indexOfFirst { it.isOpen() }.takeIf { it >= 0 } ?: phases.lastIndex - val current = phases[currentIndex] - return buildString { appendLine("Onboarding path:") - if (current.isOpen()) { - appendLine("They are in phase ${currentIndex + 1} of ${phases.size}: ${quoted(current.title)}.") - // First, when there is one: a step whose checklist is done but that was never closed - // is what a greeting can usefully open on, because it is what is quietly holding - // everything after it. - readyToClose(current, checklistsOf(current)).firstOrNull()?.let { + when (val where = standingOf(phases)) { + Standing.Finished -> appendLine("They have finished every phase of their path.") + is Standing.Choosing -> appendLine( + "${where.phases.size} phases are open to them and nothing in them is started yet: " + + where.phases.joinToString(", ") { quoted(it.title) } + ". Which one comes next is " + + "their choice -- phases are not taken in number order.", + ) + is Standing.In -> { + val current = where.phase appendLine( - "Every line of the checklist of ${quoted(it.title)} is ticked, but the step " + - "itself is still open, so what comes after it has not unlocked. It is " + - "worth asking whether they are done with it.", + "They are in phase ${phases.indexOf(current) + 1} of ${phases.size}: " + + "${quoted(current.title)}.", ) + // First, when there is one: a step whose checklist is done but that was never + // closed is what a greeting can usefully open on, because it is what is quietly + // holding everything after it. + readyToClose(current, checklistsOf(current)).firstOrNull()?.let { + appendLine( + "Every line of the checklist of ${quoted(it.title)} is ticked, but the step " + + "itself is still open, so what comes after it has not unlocked. It is " + + "worth asking whether they are done with it.", + ) + } + nextItemIn(current)?.let { appendLine("The next thing waiting for them is ${it.plain}.") } } - nextItem(phases)?.let { appendLine("The next thing waiting for them is ${it.plain}.") } - } else { - appendLine("They have finished every phase of their path.") } val empty = path.generationIssues if (empty.isNotEmpty()) { @@ -311,24 +375,75 @@ class BuddyPathTools( ?.flatMap { it.questions } ?.firstOrNull { it.id == questionId } - /** Where they are, and how much of the path is behind them. */ - private fun standing(phases: List, currentIndex: Int): String { - if (!phases[currentIndex].isOpen()) { - return "The hire's onboarding path has ${phases.size} phases and every one of them is " + - "finished. There is nothing left on it." - } + /** Where they are, how much of the path is behind them, and what else is open beside it. */ + private fun standing( + phases: List, + current: GetOnboardingPhaseForUserResponse, + ): String { // Phases behind them, never a percentage. A path mixes steps they ticked, questions they // answered and phases that came back empty; one number over those is a figure the mentor // would repeat and nobody could act on -- the same rule the arrival tool states at length. - val behind = if (currentIndex == 0) { - "It is their first phase." + val finished = phases.count { !it.isOpen() } + val behind = when (finished) { + 0 -> "Nothing is behind them yet." + 1 -> "1 of them is behind them." + else -> "$finished of them are behind them." + } + val alongside = phases.filter { it.id != current.id && !it.locked && it.isOpen() } + val sideBySide = if (alongside.isEmpty()) { + "" } else { - "The $currentIndex before it are behind them." + " Phases are not done in number order, and these are open alongside it -- the hire may " + + "switch to any of them whenever they like, so never tell them to finish a lower-numbered " + + "phase first: " + alongside.joinToString(", ") { phaseRef(it, phases) } + "." } return "The hire's onboarding path has ${phases.size} phases. They are standing in phase " + - "${currentIndex + 1}. $behind" + "${phases.indexOf(current) + 1}, ${quoted(current.title)} -- the one they are working in. " + + behind + sideBySide } + /** + * A fork: several phases open, none started, and the hire picks. + * + * Each option gets what it is for and where it starts, with the ids and link an action or a reply + * needs, so "I'll take phase 3" can be answered about phase 3 straight away. Not the whole of each + * phase: the hire is looking at the chooser on their page, and a mentor that recited every option + * in full would have read the page out. + */ + private fun StringBuilder.appendChoice( + options: List, + phases: List, + ) { + val finished = phases.count { !it.isOpen() } + appendLine( + "The hire's onboarding path has ${phases.size} phases, and $finished of them " + + "${if (finished == 1) "is" else "are"} behind them. Several phases are open now and none " + + "of them is started, so which one comes next is the hire's choice. Phases are not done in " + + "number order: never tell them to take the lowest number first, and never pick one for " + + "them -- say what each is for if they want help choosing. Their page asks them to " + + "choose, and they may already have picked one there without starting anything in it: " + + "if they say which phase they are doing, that is the phase they are in.", + ) + appendLine() + appendLine("Open to choose from:") + options.forEach { phase -> + appendLine("- ${phaseRef(phase, phases)} [phase_id: ${phase.id}]") + phase.description.takeIf { it.isNotBlank() }?.let { appendLine(" · what it is for: $it") } + nextItemIn(phase)?.let { appendLine(" · where it starts: ${it.withIds}") } + } + val locked = phases.filter { it.locked && it.isOpen() } + if (locked.isNotEmpty()) { + append(NEWLINE) + appendLine("Still locked: " + locked.joinToString(", ") { quoted(it.title) }) + } + } + + /** A phase named the way a reply should name it: its number, its title and its link. */ + private fun phaseRef( + phase: GetOnboardingPhaseForUserResponse, + phases: List, + ): String = "phase ${phases.indexOf(phase) + 1} ${quoted(phase.title)} [link: $PHASE_LINK${phase.id}]" + /** * The phase they are in, in full: what it is for, its steps, and its questions. * @@ -651,7 +766,7 @@ class BuddyPathTools( * actually are, and whatever the page calls next is often locked behind it. */ private fun StringBuilder.appendNextItem( - phases: List, + phase: GetOnboardingPhaseForUserResponse, ready: GetOnboardingStepsResponse?, ) { append(NEWLINE) @@ -662,29 +777,30 @@ class BuddyPathTools( ) return } - when (val next = nextItem(phases)) { - null -> appendLine("Nothing on their path is open right now.") + when (val next = nextItemIn(phase)) { + null -> appendLine("Nothing in this phase is open right now.") else -> appendLine("The next thing waiting for them: ${next.withIds}.") } } - /** What is ahead, by title only. */ + /** What else is left on the path, by title only. */ private fun StringBuilder.appendAhead( phases: List, - currentIndex: Int, + current: GetOnboardingPhaseForUserResponse, ) { - val ahead = phases.drop(currentIndex + 1) + // Every unfinished phase but this one, lower numbers included: phases run side by side, so + // "ahead" is what is left, not what comes after this one by position. + val ahead = phases.filter { it.id != current.id && it.isOpen() } if (ahead.isEmpty()) return append(NEWLINE) appendLine( - "Still ahead. Titles only, and deliberately so: do not read this list out, because the " + - "page they are on already lists it.", + "Still left besides this phase. Titles only, and deliberately so: do not read this list " + + "out, because the page they are on already lists it.", ) - ahead.take(AHEAD_SHOWN).forEachIndexed { offset, phase -> - val number = currentIndex + 2 + offset - val locked = if (phase.locked) " (locked until its blockers are done)" else "" - appendLine("- $number. ${quoted(phase.title)}$locked") + ahead.take(AHEAD_SHOWN).forEach { phase -> + val locked = if (phase.locked) " (locked until its blockers are done)" else " (open)" + appendLine("- ${phases.indexOf(phase) + 1}. ${quoted(phase.title)}$locked") } if (ahead.size > AHEAD_SHOWN) appendLine("- and ${ahead.size - AHEAD_SHOWN} more") } @@ -728,53 +844,54 @@ class BuddyPathTools( return openStep || questions.any { it.status != QuestionStatus.PASSED } } + /** Whether the hire has done anything in a phase yet: a step moved on, or a question answered. */ + private fun GetOnboardingPhaseForUserResponse.isStarted(): Boolean = + steps.any { it.status != StepStatus.WAITING } || + questions.any { it.status == QuestionStatus.PASSED || it.status == QuestionStatus.RETRY } + + /** When the hire last did something in a phase, as epoch millis; 0 when never. */ + private fun GetOnboardingPhaseForUserResponse.lastActivity(): Long = + steps.flatMap { listOfNotNull(it.startedAt, it.completedAt) }.maxOfOrNull { it.toEpochMilli() } ?: 0L + /** - * The first open, unlocked item on the path, by the rule the hire's own page uses. + * The first open, unlocked item in [phase], or null when the phase has nothing reachable. * - * The page's "next" button (`resolveNextAction`): phases in order, locked phases skipped - * entirely, then the first open unlocked *step* by position, and only when there is none, the - * first open *question*. Steps and questions carry separate positions, so mixing them by position - * -- which this used to do -- named a question as next while the page pointed at a step. + * The first open unlocked *step* by position, and only when there is none, the first open + * *question*. Steps and questions carry separate positions, so mixing them by position named a + * question as next while the page pointed at a step. * - * Written here against the hire-facing shape rather than reusing - * [OnboardingPositionReader], which predates questions being first-class and still walks steps - * only -- a mentor using that would send a hire past the question their phase is actually - * waiting on. Two answers to one question is a thing to reconcile, and this comment is where the - * next person will find out that it needs reconciling. + * Written here against the hire-facing shape rather than reusing [OnboardingPositionReader], + * which predates questions being first-class and still walks steps only -- a mentor using that + * would send a hire past the question their phase is actually waiting on. */ - private fun nextItem(phases: List): NextItem? { - for (phase in phases.sortedBy { it.position }) { - if (phase.locked || !phase.isOpen()) continue - - val step = phase.steps - .sortedBy { it.position } - .firstOrNull { - !it.locked && - it.status != StepStatus.FINISHED && - it.status != StepStatus.SKIPPED && - // Asked to skip and waiting on the PM: not what to tell them to do next. - !it.hasPendingSkip() - } - val question = phase.questions - .sortedBy { it.position } - .firstOrNull { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } - - if (step != null) { - return NextItem( - plain = "the step ${quoted(step.title)}", - withIds = "the step ${quoted(step.title)} [step_id: ${step.id}] " + - "[link: $STEP_LINK${step.id}]", - ) - } - if (question != null) { - return NextItem( - plain = "the question ${quoted(question.question)}", - withIds = "the question ${quoted(question.question)} " + - "[question_id: ${question.id}] [link: $QUESTION_LINK${question.id}]", - ) + private fun nextItemIn(phase: GetOnboardingPhaseForUserResponse): NextItem? { + if (phase.locked || !phase.isOpen()) return null + + val step = phase.steps + .sortedBy { it.position } + .firstOrNull { + !it.locked && + it.status != StepStatus.FINISHED && + it.status != StepStatus.SKIPPED && + // Asked to skip and waiting on the PM: not what to tell them to do next. + !it.hasPendingSkip() } + val question = phase.questions + .sortedBy { it.position } + .firstOrNull { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } + + return when { + step != null -> NextItem( + plain = "the step ${quoted(step.title)}", + withIds = "the step ${quoted(step.title)} [step_id: ${step.id}] [link: $STEP_LINK${step.id}]", + ) + question != null -> NextItem( + plain = "the question ${quoted(question.question)}", + withIds = "the question ${quoted(question.question)} " + + "[question_id: ${question.id}] [link: $QUESTION_LINK${question.id}]", + ) + else -> null } - return null } /** @@ -841,6 +958,10 @@ class BuddyPathTools( "\"Start personalization\". Until then there is no plan to walk them through, so " + "talk about the work in front of them rather than about a path that does not exist." + const val FINISHED_PATH = + "The hire's onboarding path has %d phases and every one of them is finished. There is " + + "nothing left on it." + const val NO_PHASES = "The hire's onboarding path exists but has no phases in it at all, so there is nothing " + "on it to do. Say that plainly if they ask, and point them at their PM -- an empty " + diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index 641abeac..3d50a98f 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -101,6 +101,60 @@ class BuddyPathToolsTest { assertThat(text).doesNotContain("Ship something") } + /** + * The testing session this came from: phase 1 done, phase 3 picked on the page and started, and + * the mentor sending the hire back to phase 2 -- again after being told otherwise. + */ + @Test + fun `the phase they are working in wins over a lower-numbered open one`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "Overview", steps = listOf(step("Read the wiki", StepStatus.FINISHED))), + phase(1, "Meetings", steps = listOf(step("Sit in on a standup", StepStatus.WAITING))), + phase(2, "Deployment", steps = listOf(step("Ship something", StepStatus.IN_PROGRESS))), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("standing in phase 3") + assertThat(text).contains("Ship something") + // Phase 2 is open beside it, and said to be -- never as something to finish first. + assertThat(text).contains("open alongside it") + assertThat(text).contains("never tell them to finish a lower-numbered phase first") + assertThat(text).contains("“Meetings”") + } + + @Test + fun `several open phases with nothing started are a choice, not phase 2`() { + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "Overview", steps = listOf(step("Read the wiki", StepStatus.FINISHED))), + phase(1, "Meetings", steps = listOf(step("Sit in on a standup", StepStatus.WAITING))), + phase(2, "Deployment", steps = listOf(step("Ship something", StepStatus.WAITING))), + ) + + val text = tools.execute(userId) + + assertThat(text).contains("which one comes next is the hire's choice") + assertThat(text).doesNotContain("standing in phase") + // Where each option starts, with the ids, so "I'll take 3" can be answered straight away. + assertThat(text).contains("Sit in on a standup").contains("Ship something") + assertThat(text).contains("if they say which phase they are doing, that is the phase they are in") + assertThat(tools.snapshotFor(userId)).contains("their choice") + } + + @Test + fun `the phase touched last is the one they are in when several are started`() { + val earlier = step("Sit in on a standup", StepStatus.IN_PROGRESS) + .copy(startedAt = Instant.parse("2026-09-01T10:00:00Z")) + val later = step("Ship something", StepStatus.IN_PROGRESS) + .copy(startedAt = Instant.parse("2026-09-20T10:00:00Z")) + every { onboardingPathService.findPathForUserId(userId) } returns path( + phase(0, "Meetings", steps = listOf(earlier)), + phase(1, "Deployment", steps = listOf(later)), + ) + + assertThat(tools.execute(userId)).contains("standing in phase 2") + } + @Test fun `the ids every path action needs are carried, for the current phase`() { val step = step("Set up the repo", StepStatus.WAITING) From 509b64514b838f1bea42302540ca66b80598705e Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Thu, 24 Sep 2026 19:07:53 +0200 Subject: [PATCH 18/22] Drop the retired ramp from comments that still described it Comments only. #311 removed the ramp and the idea that onboarding ends at a first accepted contribution; five comments still pointed at it. Refs #311 Co-Authored-By: Claude Opus 5.5 --- .../sprintstartbackend/onboarding/external/enums/Rigor.kt | 2 +- .../onboarding/model/ContributionWording.kt | 2 +- .../sprintstartbackend/onboarding/model/entity/UserGoal.kt | 5 +++-- .../onboarding/service/ContributionService.kt | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt index d1e606f1..50d8683f 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/Rigor.kt @@ -10,7 +10,7 @@ package com.sprintstart.sprintstartbackend.onboarding.external.enums * * This is the same discipline [CompetencySource] already applies to the ledger * ([CompetencySource.VERIFIED] outranks [CompetencySource.ASSESSED]), extended to the evidence - * stream the ramp and the metrics read. + * stream the metrics read. * * Ordered weakest-last so callers can compare: [OBSERVED] is the strongest. */ diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/ContributionWording.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/ContributionWording.kt index d67cc99d..c1350003 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/ContributionWording.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/ContributionWording.kt @@ -5,7 +5,7 @@ package com.sprintstart.sprintstartbackend.onboarding.model * * Fixed, because every hire onboards as an engineer. The words are kept in one place rather than * written into each sentence so that "merged change" and "merged changes" cannot drift apart - * across the ramp, the board, the metrics and the buddy's persona. + * across the board, the metrics and the buddy's persona. * * Bare noun: it is always rendered next to [VERB_PAST] — "merged change" — and baking the verb * into the noun produces "merged merged change" the moment a sentence needs both. diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/UserGoal.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/UserGoal.kt index f3e2feb5..f9fbac4c 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/UserGoal.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/entity/UserGoal.kt @@ -11,8 +11,9 @@ import java.util.UUID /** * The starter-work task a hire has claimed as their goal, per project. * - * The north star is time-to-first-contribution, so a hire aims at a piece of real work rather than - * at a position in a curriculum. This row is what makes that concrete. + * Picking up real work runs alongside the onboarding path, not instead of it: the path is the + * curriculum their PM's blueprint prescribes, and this row is the piece of work the hire chose + * beside it (#311). * * Stored rather than derived. Hire→task matching is a ranking, so deriving it per read * would let a hire's destination change under them between two page loads. The hire claims one from diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt index 43c239e7..4b731601 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/ContributionService.kt @@ -22,7 +22,7 @@ import java.util.UUID * and nothing to backfill. Attestations are the exception, and have a table. * * This service is only the composition rule: every [EvidenceProvider] runs, and their contributions - * become one time-ordered stream that the ramp and the metrics read. + * become one time-ordered stream that the metrics read. */ @Service class ContributionService( @@ -75,7 +75,7 @@ data class Contribution( val acceptedAt: Instant?, val returnedCount: Int, ) { - /** Accepted through the team's normal quality bar. The unit the ramp counts. */ + /** Accepted through the team's normal quality bar. The unit the metrics count. */ val isAccepted: Boolean get() = state == ContributionState.ACCEPTED From 028a5c75ea9ca2a8478bd72f784f71f645aa9a2f Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Thu, 24 Sep 2026 19:31:05 +0200 Subject: [PATCH 19/22] Show the buddy what a hire got wrong, and read a phase the page's way Two things the path read told the mentor too little or differently from the page: - A missed knowledge question said only "answered wrong before". It now carries the hire's own last answer -- their words, or the options they picked -- so the tutoring can start from what did not land. Never the correct answer: that still is not on the hire-facing shape. - The next thing in a phase was every open step by position before any question. The page takes a step in progress first, then the first open item in graph reading order, questions included. `PhaseReadingOrder` ports that order from the frontend's `arrangeRows`, and a test pins it to an ordering the frontend produced. Refs #311 Co-Authored-By: Claude Opus 5.5 --- .../onboarding/service/BuddyPathTools.kt | 114 +++++++++++++----- .../onboarding/service/PhaseReadingOrder.kt | 82 +++++++++++++ .../onboarding/service/BuddyPathToolsTest.kt | 80 +++++++++++- .../service/PhaseReadingOrderTest.kt | 56 +++++++++ 4 files changed, 299 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrder.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrderTest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index c78a5747..a5bb9f44 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -8,6 +8,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnb import com.sprintstart.sprintstartbackend.onboarding.model.response.question.GetOnboardingQuestionForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse +import com.sprintstart.sprintstartbackend.onboarding.repository.QuestionAttemptRepository import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject @@ -52,6 +53,7 @@ import java.util.UUID class BuddyPathTools( private val onboardingPathService: OnboardingPathService, private val onboardingTaskService: OnboardingTaskService, + private val questionAttemptRepository: QuestionAttemptRepository, ) { /** * The path tool, mounted only for a hire who has a path. @@ -107,7 +109,7 @@ class BuddyPathTools( val checklists = checklistsOf(current) appendLine(standing(phases, current)) appendLine() - appendCurrentPhase(current, phases.indexOf(current), phases) + appendCurrentPhase(current, phases.indexOf(current), phases, lastWrongAnswers(userId, current)) appendReadyToClose(current, phases, checklists) appendCurrentTasks(stepTheyAreOn(current), checklists) appendNextItem(current, readyToClose(current, checklists).firstOrNull()) @@ -462,6 +464,7 @@ class BuddyPathTools( phase: GetOnboardingPhaseForUserResponse, index: Int, phases: List, + lastAnswers: Map = emptyMap(), ) { appendLine( "Phase ${index + 1} of ${phases.size}: ${quoted(phase.title)} " + @@ -504,7 +507,7 @@ class BuddyPathTools( "Knowledge questions. They count like steps, so a phase whose steps are done and " + "whose questions are unanswered is still the phase they are standing in:", ) - questions.take(ITEMS_SHOWN).forEach { appendQuestion(it, phase, graph) } + questions.take(ITEMS_SHOWN).forEach { appendQuestion(it, phase, graph, lastAnswers[it.id]) } if (questions.size > ITEMS_SHOWN) appendLine("- and ${questions.size - ITEMS_SHOWN} more") } } @@ -650,6 +653,7 @@ class BuddyPathTools( question: GetOnboardingQuestionForUserResponse, phase: GetOnboardingPhaseForUserResponse, graph: PhaseGraph, + lastAnswer: String?, ) { val numbers = graph.numbers val state = when (question.status) { @@ -678,6 +682,15 @@ class BuddyPathTools( // the material in the conversation comes first; a refresher step is for when what they // missed is more than one explanation, so it is still there tomorrow. if (question.status == QuestionStatus.RETRY) { + // What they actually said, so the tutoring can start from what did not land rather than + // from the whole of the material. Their own answer, never the correct one. + lastAnswer?.let { + appendLine( + " · their last answer, which was not right: ${quoted(it)}. Start from what it " + + "shows they understood and where it went wrong -- without saying what the " + + "right answer is.", + ) + } // Placed before the question, so the refresher is what opens it, and after what the // question waits on now -- or, when that is nothing, after where the hire is. The same // inference the action applies, spelled out so the mentor passes both halves. @@ -692,6 +705,29 @@ class BuddyPathTools( } } + /** + * The hire's own last answer to every question of [phase] they got wrong and have not passed since. + * + * The mentor used to know only *that* an answer was wrong, which left it "let's go through the + * material again" and nothing sharper. The answer itself is the diagnosis: it is the one thing + * that says which idea did not land. Never the correct answer -- that is not on the hire-facing + * shape and stays out of the mentor's reach. A choice is given as the option labels they picked, + * which they know are wrong already. + */ + private fun lastWrongAnswers(userId: UUID, phase: GetOnboardingPhaseForUserResponse): Map = + phase.questions + .filter { it.status == QuestionStatus.RETRY } + .mapNotNull { question -> + val last = questionAttemptRepository + .findAllByQuestionIdAndUserIdOrderByCreatedAtDesc(question.id, userId) + .firstOrNull() + ?.takeUnless { it.correct } + ?: return@mapNotNull null + val labels = question.options.filter { it.id in last.selectedOptionIds }.map { it.label } + val answer = last.textAnswer?.takeIf { it.isNotBlank() } ?: labels.joinToString("; ") + answer.takeIf { it.isNotBlank() }?.let { question.id to it.take(ANSWER_SHOWN) } + }.toMap() + /** * Where an item sits in its phase's graph: what it comes after, whether that has locked it, and * what finishing it opens. @@ -854,46 +890,57 @@ class BuddyPathTools( steps.flatMap { listOfNotNull(it.startedAt, it.completedAt) }.maxOfOrNull { it.toEpochMilli() } ?: 0L /** - * The first open, unlocked item in [phase], or null when the phase has nothing reachable. + * The item to do next in [phase], or null when the phase has nothing reachable. * - * The first open unlocked *step* by position, and only when there is none, the first open - * *question*. Steps and questions carry separate positions, so mixing them by position named a - * question as next while the page pointed at a step. + * The page's own rule (`nextItemInPhase` in the frontend), so "up next" on the page and the next + * thing the buddy names are the same item: a step already in progress first -- it is where they + * left off -- and then the first open item in the order the phase graph reads, steps and + * questions mixed, because a question is a node of the same graph and often stands between two + * steps. [PhaseReadingOrder] is that order. * - * Written here against the hire-facing shape rather than reusing [OnboardingPositionReader], - * which predates questions being first-class and still walks steps only -- a mentor using that - * would send a hire past the question their phase is actually waiting on. + * This used to take every open step by position before any question, which named a step as next + * while the page pointed at the question in front of it. */ private fun nextItemIn(phase: GetOnboardingPhaseForUserResponse): NextItem? { if (phase.locked || !phase.isOpen()) return null - val step = phase.steps - .sortedBy { it.position } - .firstOrNull { - !it.locked && - it.status != StepStatus.FINISHED && - it.status != StepStatus.SKIPPED && - // Asked to skip and waiting on the PM: not what to tell them to do next. - !it.hasPendingSkip() - } - val question = phase.questions - .sortedBy { it.position } - .firstOrNull { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } + phase.steps.sortedBy { it.position }.firstOrNull { it.status == StepStatus.IN_PROGRESS }?.let { + return stepItem(it) + } - return when { - step != null -> NextItem( - plain = "the step ${quoted(step.title)}", - withIds = "the step ${quoted(step.title)} [step_id: ${step.id}] [link: $STEP_LINK${step.id}]", - ) - question != null -> NextItem( - plain = "the question ${quoted(question.question)}", - withIds = "the question ${quoted(question.question)} " + - "[question_id: ${question.id}] [link: $QUESTION_LINK${question.id}]", - ) - else -> null + val steps = phase.steps.sortedBy { it.position } + val questions = phase.questions.sortedBy { it.position } + val byId = steps.associateBy { it.id } + val questionsById = questions.associateBy { it.id } + val order = PhaseReadingOrder.of( + steps.map { it.id to it.blockerIds } + questions.map { it.id to it.blockerIds }, + ) + for (id in order) { + val step = byId[id] + // Waiting, unlocked, and not asked to skip -- a step waiting on the PM's decision is not + // what to tell them to do next. + if (step != null && step.status == StepStatus.WAITING && !step.locked && !step.hasPendingSkip()) { + return stepItem(step) + } + val question = questionsById[id] + if (question != null && + (question.status == QuestionStatus.OPEN || question.status == QuestionStatus.RETRY) + ) { + return NextItem( + plain = "the question ${quoted(question.question)}", + withIds = "the question ${quoted(question.question)} " + + "[question_id: ${question.id}] [link: $QUESTION_LINK${question.id}]", + ) + } } + return null } + private fun stepItem(step: GetOnboardingStepsResponse) = NextItem( + plain = "the step ${quoted(step.title)}", + withIds = "the step ${quoted(step.title)} [step_id: ${step.id}] [link: $STEP_LINK${step.id}]", + ) + /** * One named next thing, in the two forms it is needed in. * @@ -920,6 +967,9 @@ class BuddyPathTools( /** How many phase titles ahead are named, and how many empty phases. */ const val AHEAD_SHOWN = 12 + /** How much of a hire's own last answer is quoted -- enough to see the idea, not an essay. */ + const val ANSWER_SHOWN = 300 + /** How many expected outcomes of one step are quoted. Enough to say what "done" means. */ const val OUTCOMES_SHOWN = 3 diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrder.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrder.kt new file mode 100644 index 00000000..4881dc3b --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrder.kt @@ -0,0 +1,82 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import java.util.UUID + +/** + * The order a phase's items read in on the hire's page: row by row of the phase's dependency + * graph, and inside a row in the order the graph draws them. + * + * A port of `arrangeRows` in the frontend's `features/onboarding/graph/layout.ts`, and it has to stay + * one: the page's "up next" is the first open item in this order, and the buddy names the next thing + * from the same order, so the two can only agree while the rule is the same on both sides. Change one + * and change the other, including the tie-breaks. + * + * - A node's **row** is one below the deepest thing it waits on. A cycle -- which the backend refuses, + * but data can still hold one -- is cut where it is found. + * - Inside a row, a few passes of the **barycentre** heuristic pull every node towards the average + * column of what it connects to in the row above, then below. The input order breaks ties, so a + * graph with no edges reads in the order it came in. + */ +internal object PhaseReadingOrder { + private const val PASSES = 4 + + /** + * Orders [nodes] -- ids with what each waits on, in input order -- the way the page reads them. + * + * The input order is the page's: steps by position, then questions by position. + */ + fun of(nodes: List>>): List { + if (nodes.isEmpty()) return emptyList() + val ids = nodes.map { it.first } + val inGraph = ids.toSet() + val blockers = nodes.associate { (id, waitsOn) -> id to waitsOn.filter { it in inGraph && it != id } } + val dependents = ids.associateWith { mutableListOf() } + blockers.forEach { (id, waitsOn) -> waitsOn.forEach { dependents[it]?.add(id) } } + + val ranks = ranks(ids, blockers) + val rows = List(ranks.values.max() + 1) { mutableListOf() } + ids.forEach { rows[ranks.getValue(it)].add(it) } + + val inputIndex = ids.withIndex().associate { it.value to it.index } + val column = HashMap() + val indexRow = { row: List -> row.forEachIndexed { index, id -> column[id] = index } } + rows.forEach(indexRow) + + val sortRow = { row: MutableList, neighboursOf: (UUID) -> List -> + val weight = row.associateWith { id -> + val neighbours = neighboursOf(id).filter { it in column } + if (neighbours.isEmpty()) { + column.getValue(id).toDouble() + } else { + neighbours.sumOf { column.getValue(it).toDouble() } / neighbours.size + } + } + row.sortWith(compareBy { weight.getValue(it) }.thenBy { inputIndex.getValue(it) }) + indexRow(row) + } + + repeat(PASSES) { + for (index in 1 until rows.size) sortRow(rows[index]) { blockers[it].orEmpty() } + for (index in rows.size - 2 downTo 0) sortRow(rows[index]) { dependents[it].orEmpty() } + } + return rows.flatten() + } + + private fun ranks(ids: List, blockers: Map>): Map { + val ranks = HashMap() + val visiting = HashSet() + + fun rankOf(id: UUID): Int { + ranks[id]?.let { return it } + if (id in visiting) return 0 + visiting += id + val rank = (blockers[id].orEmpty().maxOfOrNull { rankOf(it) } ?: -1) + 1 + visiting -= id + ranks[id] = rank + return rank + } + + ids.forEach { rankOf(it) } + return ranks + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index 3d50a98f..a9435973 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -5,6 +5,7 @@ import com.sprintstart.sprintstartbackend.onboarding.external.enums.GenerationSt import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepType +import com.sprintstart.sprintstartbackend.onboarding.model.entity.QuestionAttempt import com.sprintstart.sprintstartbackend.onboarding.model.response.path.GetOnboardingPathForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.path.OnboardingGenerationIssueResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse @@ -13,6 +14,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.question.Que import com.sprintstart.sprintstartbackend.onboarding.model.response.skip.GetOnboardingStepSkipResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTaskResponse +import com.sprintstart.sprintstartbackend.onboarding.repository.QuestionAttemptRepository import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -38,7 +40,12 @@ class BuddyPathToolsTest { private val onboardingTaskService: OnboardingTaskService = mockk { every { getOnboardingTasksByStepId(any()) } returns emptyList() } - private val tools = BuddyPathTools(onboardingPathService, onboardingTaskService) + + // No attempts unless a case puts some there. + private val questionAttemptRepository: QuestionAttemptRepository = mockk { + every { findAllByQuestionIdAndUserIdOrderByCreatedAtDesc(any(), any()) } returns mutableListOf() + } + private val tools = BuddyPathTools(onboardingPathService, onboardingTaskService, questionAttemptRepository) private val userId = UUID.randomUUID() @@ -411,6 +418,42 @@ class BuddyPathToolsTest { assertThat(text).contains("never the answer") } + /** The buddy asked for it: knowing *that* an answer was wrong gave it nothing to teach against. */ + @Test + fun `a missed question carries the hire's own last answer, never the right one`() { + val missed = question("Who runs the retro?", QuestionStatus.RETRY) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", questions = listOf(missed))) + every { questionAttemptRepository.findAllByQuestionIdAndUserIdOrderByCreatedAtDesc(missed.id, userId) } returns + mutableListOf(attempt(missed.id, correct = false, text = "The PM")) + + val text = tools.execute(userId) + + assertThat(text).contains("their last answer, which was not right: “The PM”") + assertThat(text).contains("without saying what the right answer is") + } + + @Test + fun `a missed choice is quoted as the options they picked`() { + val missed = question("Which meeting sets scope?", QuestionStatus.RETRY, options = listOf("Planning", "Retro")) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", questions = listOf(missed))) + every { questionAttemptRepository.findAllByQuestionIdAndUserIdOrderByCreatedAtDesc(missed.id, userId) } returns + mutableListOf(attempt(missed.id, correct = false, options = listOf(missed.options[1].id))) + + assertThat(tools.execute(userId)).contains("their last answer, which was not right: “Retro”") + } + + @Test + fun `a question that is not open after a miss carries no answer`() { + val passed = question("Who runs the retro?", QuestionStatus.PASSED) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", questions = listOf(passed))) + + assertThat(tools.execute(userId)).doesNotContain("their last answer") + verify(exactly = 0) { questionAttemptRepository.findAllByQuestionIdAndUserIdOrderByCreatedAtDesc(any(), any()) } + } + @Test fun `the phase is described as a graph, with what each item opens and what is open now`() { // Told only about locks, the mentor read the numbers as a sequence -- "after #6 comes #7" -- @@ -440,6 +483,28 @@ class BuddyPathToolsTest { assertThat(tools.execute(userId)).contains("The next thing waiting for them: the step “Clone the repository”") } + /** The page puts the question first when the step waits on it; the buddy has to as well. */ + @Test + fun `a question the step waits on is the next thing, as on the page`() { + val question = question("Who runs the retro?", QuestionStatus.OPEN) + val step = step("Run your first retro", StepStatus.WAITING, blockers = setOf(question.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Meetings", steps = listOf(step), questions = listOf(question))) + + assertThat(tools.execute(userId)) + .contains("The next thing waiting for them: the question “Who runs the retro?”") + } + + @Test + fun `a step in progress is the next thing, wherever it sits in the graph`() { + val first = step("Clone the repository", StepStatus.WAITING) + val started = step("Run the tests", StepStatus.IN_PROGRESS, blockers = setOf(first.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Setup", steps = listOf(first, started))) + + assertThat(tools.execute(userId)).contains("The next thing waiting for them: the step “Run the tests”") + } + @Test fun `a refresher for a missed question is placed in front of that question`() { val before = step("Read the retro guide", StepStatus.FINISHED) @@ -681,6 +746,19 @@ class BuddyPathToolsTest { finished = finished, ) + private fun attempt( + questionId: UUID, + correct: Boolean, + text: String? = null, + options: List = emptyList(), + ) = QuestionAttempt( + questionId = questionId, + userId = userId, + correct = correct, + selectedOptionIds = options.toMutableList(), + textAnswer = text, + ) + private var questionPosition = 100 private fun question( diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrderTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrderTest.kt new file mode 100644 index 00000000..2fe26c3e --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PhaseReadingOrderTest.kt @@ -0,0 +1,56 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +/** + * The phase's reading order has to be the frontend's, item for item, or the page's "up next" and the + * buddy's next thing drift apart. + */ +class PhaseReadingOrderTest { + private val ids = listOf("s1", "s2", "s3", "s4", "q1", "q2").associateWith { UUID.randomUUID() } + + private fun node(name: String, vararg waitsOn: String) = + ids.getValue(name) to waitsOn.map { ids.getValue(it) }.toSet() + + private fun names(order: List) = order.map { id -> ids.entries.first { it.value == id }.key } + + @Test + fun `a graph with no edges reads in the order it came in`() { + assertThat(names(PhaseReadingOrder.of(listOf(node("s1"), node("s2"), node("q1"))))) + .containsExactly("s1", "s2", "q1") + } + + @Test + fun `what an item waits on reads before it`() { + assertThat(names(PhaseReadingOrder.of(listOf(node("s1", "q1"), node("q1"))))) + .containsExactly("q1", "s1") + } + + /** + * Produced by the frontend's `orderByGraph` for the same input -- including `s2` pulled in front of + * `s1` by the barycentre pass, which is the part a simpler rule would get wrong. + */ + @Test + fun `a mixed graph reads exactly as the frontend orders it`() { + val order = PhaseReadingOrder.of( + listOf( + node("s1"), + node("s2"), + node("s3", "s2"), + node("s4", "s1", "q1"), + node("q1"), + node("q2", "s3"), + ), + ) + + assertThat(names(order)).containsExactly("s2", "s1", "q1", "s3", "s4", "q2") + } + + @Test + fun `a cycle is cut rather than recursed into forever`() { + assertThat(names(PhaseReadingOrder.of(listOf(node("s1", "s2"), node("s2", "s1"))))) + .containsExactlyInAnyOrder("s1", "s2") + } +} From 2efc836e63319c4aab9ec8274a812a952538a2c0 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Thu, 24 Sep 2026 19:44:59 +0200 Subject: [PATCH 20/22] Split where-the-hire-stands out of the path tool detekt flagged `BuddyPathTools` as too large once it learned phases run side by side. Deciding where the hire stands and what comes next in a phase moves to `PathStanding.kt`, next to `PhaseReadingOrder`; the path tool keeps turning that into text. No behaviour change. Refs #311 Co-Authored-By: Claude Opus 5.5 --- .../onboarding/service/BuddyPathTools.kt | 143 ++---------------- .../onboarding/service/PathStanding.kt | 134 ++++++++++++++++ 2 files changed, 143 insertions(+), 134 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index a5bb9f44..c709953e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -88,10 +88,10 @@ class BuddyPathTools( val phases = path.phases.sortedBy { it.position } if (phases.isEmpty()) return NO_PHASES - return when (val where = standingOf(phases)) { + return when (val where = PathStanding.of(phases)) { // A path whose phases all came back empty was never started, let alone finished: say // what is actually there, which is nothing, rather than congratulate them. - Standing.Finished -> if (phases.all { it.steps.isEmpty() && it.questions.isEmpty() }) { + PathStanding.Finished -> if (phases.all { it.steps.isEmpty() && it.questions.isEmpty() }) { buildString { appendCurrentPhase(phases.last(), phases.lastIndex, phases) appendEmptyPhases(path) @@ -99,12 +99,12 @@ class BuddyPathTools( } else { FINISHED_PATH.format(phases.size) } - is Standing.Choosing -> buildString { + is PathStanding.Choosing -> buildString { appendChoice(where.phases, phases) appendEmptyPhases(path) append(NEWLINE + CLOSING) } - is Standing.In -> buildString { + is PathStanding.In -> buildString { val current = where.phase val checklists = checklistsOf(current) appendLine(standing(phases, current)) @@ -120,49 +120,6 @@ class BuddyPathTools( } } - /** - * Where the hire stands on a path whose phases run side by side. - * - * The rule the hire's own page follows (`resolveNextAction` in the frontend), so the mentor and - * the page cannot disagree about it: a phase they have already started, most recently touched - * first; else the only phase that is open; else a real choice between the open phases. Locked - * phases are never candidates. - * - * This used to be "the first phase with anything open, by position", which read a blueprint as a - * line: a hire who finished phase 1 and picked phase 3 on their page was told to start phase 2, - * and told it again when they said otherwise. - * - * A phase picked on the page but not started yet is invisible here -- the choice is page state, - * not stored anywhere -- which is why [Standing.Choosing] tells the mentor to take the hire's word - * for which phase they are doing. - */ - private fun standingOf(phases: List): Standing { - val candidates = phases.filter { !it.locked && nextItemIn(it) != null } - if (candidates.isEmpty()) { - // Nothing reachable: either done, or everything left is locked or waiting on a skip - // decision -- then the first unfinished phase is still what there is to talk about. - return phases.firstOrNull { it.isOpen() }?.let { Standing.In(it) } ?: Standing.Finished - } - val started = candidates.filter { it.isStarted() }.maxByOrNull { it.lastActivity() } - val pick = started ?: candidates.singleOrNull() - return pick?.let { Standing.In(it) } ?: Standing.Choosing(candidates) - } - - /** The three answers to "where are they on their path". */ - private sealed interface Standing { - /** Working in one phase -- the one to describe in full. */ - data class In( - val phase: GetOnboardingPhaseForUserResponse, - ) : Standing - - /** Several phases open and none of them started: the hire picks. */ - data class Choosing( - val phases: List, - ) : Standing - - data object Finished : Standing - } - /** * The checklist of every step in [phase] the hire could still finish, keyed by step id. * @@ -306,14 +263,14 @@ class BuddyPathTools( return buildString { appendLine("Onboarding path:") - when (val where = standingOf(phases)) { - Standing.Finished -> appendLine("They have finished every phase of their path.") - is Standing.Choosing -> appendLine( + when (val where = PathStanding.of(phases)) { + PathStanding.Finished -> appendLine("They have finished every phase of their path.") + is PathStanding.Choosing -> appendLine( "${where.phases.size} phases are open to them and nothing in them is started yet: " + where.phases.joinToString(", ") { quoted(it.title) } + ". Which one comes next is " + "their choice -- phases are not taken in number order.", ) - is Standing.In -> { + is PathStanding.In -> { val current = where.phase appendLine( "They are in phase ${phases.indexOf(current) + 1} of ${phases.size}: " + @@ -867,92 +824,10 @@ class BuddyPathTools( ) } - /** Whether the hire asked to skip this step and their PM has not decided yet. */ - private fun GetOnboardingStepsResponse.hasPendingSkip(): Boolean = skip != null && skip.accepted == null - /** Whether a step can still be finished: not locked, and neither finished nor skipped. */ private fun GetOnboardingStepsResponse.isFinishable(): Boolean = !locked && (status == StepStatus.WAITING || status == StepStatus.IN_PROGRESS) - /** Whether a phase still has anything open: an unfinished step, or an unpassed question. */ - private fun GetOnboardingPhaseForUserResponse.isOpen(): Boolean { - val openStep = steps.any { it.status != StepStatus.FINISHED && it.status != StepStatus.SKIPPED } - return openStep || questions.any { it.status != QuestionStatus.PASSED } - } - - /** Whether the hire has done anything in a phase yet: a step moved on, or a question answered. */ - private fun GetOnboardingPhaseForUserResponse.isStarted(): Boolean = - steps.any { it.status != StepStatus.WAITING } || - questions.any { it.status == QuestionStatus.PASSED || it.status == QuestionStatus.RETRY } - - /** When the hire last did something in a phase, as epoch millis; 0 when never. */ - private fun GetOnboardingPhaseForUserResponse.lastActivity(): Long = - steps.flatMap { listOfNotNull(it.startedAt, it.completedAt) }.maxOfOrNull { it.toEpochMilli() } ?: 0L - - /** - * The item to do next in [phase], or null when the phase has nothing reachable. - * - * The page's own rule (`nextItemInPhase` in the frontend), so "up next" on the page and the next - * thing the buddy names are the same item: a step already in progress first -- it is where they - * left off -- and then the first open item in the order the phase graph reads, steps and - * questions mixed, because a question is a node of the same graph and often stands between two - * steps. [PhaseReadingOrder] is that order. - * - * This used to take every open step by position before any question, which named a step as next - * while the page pointed at the question in front of it. - */ - private fun nextItemIn(phase: GetOnboardingPhaseForUserResponse): NextItem? { - if (phase.locked || !phase.isOpen()) return null - - phase.steps.sortedBy { it.position }.firstOrNull { it.status == StepStatus.IN_PROGRESS }?.let { - return stepItem(it) - } - - val steps = phase.steps.sortedBy { it.position } - val questions = phase.questions.sortedBy { it.position } - val byId = steps.associateBy { it.id } - val questionsById = questions.associateBy { it.id } - val order = PhaseReadingOrder.of( - steps.map { it.id to it.blockerIds } + questions.map { it.id to it.blockerIds }, - ) - for (id in order) { - val step = byId[id] - // Waiting, unlocked, and not asked to skip -- a step waiting on the PM's decision is not - // what to tell them to do next. - if (step != null && step.status == StepStatus.WAITING && !step.locked && !step.hasPendingSkip()) { - return stepItem(step) - } - val question = questionsById[id] - if (question != null && - (question.status == QuestionStatus.OPEN || question.status == QuestionStatus.RETRY) - ) { - return NextItem( - plain = "the question ${quoted(question.question)}", - withIds = "the question ${quoted(question.question)} " + - "[question_id: ${question.id}] [link: $QUESTION_LINK${question.id}]", - ) - } - } - return null - } - - private fun stepItem(step: GetOnboardingStepsResponse) = NextItem( - plain = "the step ${quoted(step.title)}", - withIds = "the step ${quoted(step.title)} [step_id: ${step.id}] [link: $STEP_LINK${step.id}]", - ) - - /** - * One named next thing, in the two forms it is needed in. - * - * The greeting gets [plain] and the tool result gets [withIds], because an id in a greeting is - * an identifier in front of the hire and an id missing from a tool result is an action the - * mentor cannot offer. - */ - private data class NextItem( - val plain: String, - val withIds: String, - ) - /** * Not private, for the same reason [BuddyToolExecutor]'s tool names are not: the chip catalog in * [BuddySuggestionService] binds to this constant, so renaming the tool stops that catalog @@ -1000,7 +875,7 @@ class BuddyPathTools( const val PHASE_LINK = "/onboarding?phase=" /** Titles are somebody else's text, so they are quoted rather than run into the sentence. */ - private fun quoted(text: String): String = "“" + text + "”" + internal fun quoted(text: String): String = "“" + text + "”" const val NO_PATH = "The hire has no onboarding path yet -- nobody has generated one from their project's " + diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt new file mode 100644 index 00000000..dd729107 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt @@ -0,0 +1,134 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse + +/** + * Where the hire stands on a path whose phases run side by side, and what comes next in a phase. + * + * Both are the hire's own page's rules (`resolveNextAction` and `nextItemInPhase` in the frontend), + * restated here so the mentor and the page cannot disagree about them. Split out of [BuddyPathTools], + * which turns the answer into text; this only decides it. + */ +internal sealed interface PathStanding { + /** Working in one phase -- the one to describe in full. */ + data class In( + val phase: GetOnboardingPhaseForUserResponse, + ) : PathStanding + + /** Several phases open and none of them started: the hire picks. */ + data class Choosing( + val phases: List, + ) : PathStanding + + data object Finished : PathStanding + + companion object { + /** + * Where the hire stands: a phase they have already started, most recently touched first; + * else the only phase that is open; else a real choice between the open phases. Locked + * phases are never candidates. + * + * This used to be "the first phase with anything open, by position", which read a blueprint + * as a line: a hire who finished phase 1 and picked phase 3 on their page was told to start + * phase 2, and told it again when they said otherwise. + * + * A phase picked on the page but not started yet is invisible here -- the choice is page + * state, not stored anywhere -- which is why [Choosing] tells the mentor to take the hire's + * word for which phase they are doing. + */ + fun of(phases: List): PathStanding { + val candidates = phases.filter { !it.locked && nextItemIn(it) != null } + if (candidates.isEmpty()) { + // Nothing reachable: either done, or everything left is locked or waiting on a skip + // decision -- then the first unfinished phase is still what there is to talk about. + return phases.firstOrNull { it.isOpen() }?.let { In(it) } ?: Finished + } + val started = candidates.filter { it.isStarted() }.maxByOrNull { it.lastActivity() } + val pick = started ?: candidates.singleOrNull() + return pick?.let { In(it) } ?: Choosing(candidates) + } + } +} + +/** + * One named next thing, in the two forms it is needed in. + * + * The greeting gets [plain] and the tool result gets [withIds], because an id in a greeting is an + * identifier in front of the hire and an id missing from a tool result is an action the mentor + * cannot offer. + */ +internal data class NextPathItem( + val plain: String, + val withIds: String, +) + +/** + * The item to do next in [phase], or null when the phase has nothing reachable. + * + * A step already in progress first -- it is where they left off -- and then the first open item in + * the order the phase graph reads, steps and questions mixed, because a question is a node of the + * same graph and often stands between two steps. [PhaseReadingOrder] is that order. + * + * This used to take every open step by position before any question, which named a step as next + * while the page pointed at the question in front of it. + */ +internal fun nextItemIn(phase: GetOnboardingPhaseForUserResponse): NextPathItem? { + if (phase.locked || !phase.isOpen()) return null + + val steps = phase.steps.sortedBy { it.position } + val questions = phase.questions.sortedBy { it.position } + steps.firstOrNull { it.status == StepStatus.IN_PROGRESS }?.let { return stepItem(it) } + + val stepsById = steps.associateBy { it.id } + val questionsById = questions.associateBy { it.id } + val order = PhaseReadingOrder.of( + steps.map { it.id to it.blockerIds } + questions.map { it.id to it.blockerIds }, + ) + for (id in order) { + stepsById[id]?.takeIf { it.isReadyToStart() }?.let { return stepItem(it) } + questionsById[id] + ?.takeIf { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } + ?.let { question -> + return NextPathItem( + plain = "the question ${BuddyPathTools.quoted(question.question)}", + withIds = "the question ${BuddyPathTools.quoted(question.question)} " + + "[question_id: ${question.id}] [link: ${BuddyPathTools.QUESTION_LINK}${question.id}]", + ) + } + } + return null +} + +private fun stepItem(step: GetOnboardingStepsResponse) = NextPathItem( + plain = "the step ${BuddyPathTools.quoted(step.title)}", + withIds = "the step ${BuddyPathTools.quoted(step.title)} [step_id: ${step.id}] " + + "[link: ${BuddyPathTools.STEP_LINK}${step.id}]", +) + +/** + * Waiting, unlocked, and not asked to skip -- a step waiting on the PM's decision is not what to + * tell them to do next. + */ +private fun GetOnboardingStepsResponse.isReadyToStart(): Boolean = + status == StepStatus.WAITING && !locked && !hasPendingSkip() + +/** Whether the hire asked to skip this step and their PM has not decided yet. */ +internal fun GetOnboardingStepsResponse.hasPendingSkip(): Boolean = skip != null && skip.accepted == null + +/** Whether a phase still has anything open: an unfinished step, or an unpassed question. */ +internal fun GetOnboardingPhaseForUserResponse.isOpen(): Boolean { + val openStep = steps.any { it.status != StepStatus.FINISHED && it.status != StepStatus.SKIPPED } + return openStep || questions.any { it.status != QuestionStatus.PASSED } +} + +/** Whether the hire has done anything in a phase yet: a step moved on, or a question answered. */ +private fun GetOnboardingPhaseForUserResponse.isStarted(): Boolean = + steps.any { it.status != StepStatus.WAITING } || + questions.any { it.status == QuestionStatus.PASSED || it.status == QuestionStatus.RETRY } + +/** When the hire last did something in a phase, as epoch millis; 0 when never. */ +private fun GetOnboardingPhaseForUserResponse.lastActivity(): Long = + steps.flatMap { listOfNotNull(it.startedAt, it.completedAt) }.maxOfOrNull { it.toEpochMilli() } ?: 0L From 2024581ba036a22251f222a9819539980e03773d Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Thu, 24 Sep 2026 20:13:27 +0200 Subject: [PATCH 21/22] Tidy the path buddy after a self-review The checklist the path tool shows is now the one of the step it names next (same reading-order rule as the page) rather than the first open step by position. Path actions reuse hasPendingSkip, dispatch explicitly instead of through an else branch, and a few stale doc references (BuddyActionService -> BuddyPathActions, "three" path actions) and awkward wraps are fixed. Co-Authored-By: Claude Opus 5.5 --- .../external/enums/BoardCardKind.kt | 11 +++--- .../external/enums/BuddyActionType.kt | 3 +- .../model/request/buddy/BuddyActionRequest.kt | 4 +- .../response/buddy/BuddyActionResponse.kt | 4 +- .../onboarding/service/BuddyActionService.kt | 8 ++-- .../onboarding/service/BuddyPathActions.kt | 14 ++++--- .../onboarding/service/BuddyPathTools.kt | 23 ++--------- .../onboarding/service/PathStanding.kt | 38 ++++++++++++------- .../onboarding/service/PathStepPlacement.kt | 2 +- .../onboarding/service/BuddyPathToolsTest.kt | 26 +++++++++++++ 10 files changed, 79 insertions(+), 54 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt index 5ad53d91..f374375b 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt @@ -19,10 +19,9 @@ enum class BoardCardKind( /** * What still has to be true before this hire can work: accounts, access, a machine that builds. * - * Baseline rather than mentor-placed: nobody should depend - * on a model noticing that somebody has been unable to clone the repository for a week. It is - * ensured on every board read and is the one card that is *most* useful on day one, when the - * board is otherwise thin. + * Baseline rather than mentor-placed: nobody should depend on a model noticing that somebody has + * been unable to clone the repository for a week. It is ensured on every board read and is the + * one card that is *most* useful on day one, when the board is otherwise thin. * * It shows outstanding work; it does not withhold anything. An unsettled step never * stops a hire claiming a task, and nothing anywhere consults these rows before serving them. @@ -51,8 +50,8 @@ enum class BoardCardKind( * The task the hire is on, and where it came from. * * Not part of the baseline, because it is only true some of the time — somebody with no claimed - * goal is not "between tasks", they simply have no task, and a card about nothing - * is worse than no card. The mentor places it, and confirming `claim_goal` places it too. + * goal is not "between tasks", they simply have no task, and a card about nothing is worse than + * no card. The mentor places it, and confirming `claim_goal` places it too. */ CURRENT_TASK(Placement.MENTOR), diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt index b7c642cc..ac884c22 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BuddyActionType.kt @@ -41,7 +41,8 @@ enum class BuddyActionType( RECORD_ASSESSMENT("record_assessment", "Save this placement"), /** - * The three path actions: the mentor moving the hire along the curriculum their PM wrote. + * The path actions, from here to [REQUEST_SKIP]: the mentor moving the hire along the curriculum + * their PM wrote. * * They are what turns the buddy from a second onboarding mechanism into the tutor for the first * one. One line decides how far that goes, and it is worth stating here rather than only in the diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt index 9a62f22f..6533effc 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/BuddyActionRequest.kt @@ -29,8 +29,8 @@ data class BuddyActionRequest( val competencyKey: String? = null, val level: String? = null, /** - * The path node a path action is aimed at: [stepId] for `complete_step`, [questionId] for - * `answer_question`, [phaseId] for `add_path_step`. + * The path node a path action is aimed at: [stepId] for `complete_step` and `request_skip`, + * [questionId] for `answer_question`, [phaseId] for `add_path_step`. * * Echoed back verbatim like every other payload here, and re-resolved server-side through the * caller's *own* path — so an id that belongs to somebody else's onboarding is not found rather diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt index 79339579..2222c65a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/buddy/BuddyActionResponse.kt @@ -4,8 +4,8 @@ package com.sprintstart.sprintstartbackend.onboarding.model.response.buddy * The outcome of a confirmed buddy action, as a single line the buddy can relay in the thread. * * [ok] is true when the action changed something, false when it legibly could not (e.g. no current - * task to open a packet for). A false outcome is a handled state, not an error — [message] always carries a reason the - * hire can read either way. + * task to open a packet for). A false outcome is a handled state, not an error — [message] always + * carries a reason the hire can read either way. */ data class BuddyActionResponse( val ok: Boolean, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 1157564b..3d18d8b1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -631,11 +631,11 @@ class BuddyActionService( // reason lines. BuddyActionType.RECORD_ASSESSMENT -> "record where a chat placed you" // Unused for the same reason again: a path is not project-scoped either. - BuddyActionType.COMPLETE_STEP -> "tick a step off their path" - BuddyActionType.COMPLETE_TASK -> "tick a line off their checklist" + BuddyActionType.COMPLETE_STEP -> "tick a step off your path" + BuddyActionType.COMPLETE_TASK -> "tick a line off your checklist" BuddyActionType.ANSWER_QUESTION -> "send an answer to a question" - BuddyActionType.ADD_PATH_STEP -> "add a step to their path" - BuddyActionType.REQUEST_SKIP -> "ask their PM to skip a step" + BuddyActionType.ADD_PATH_STEP -> "add a step to your path" + BuddyActionType.REQUEST_SKIP -> "ask your PM to skip a step" BuddyActionType.PLACE_CHECKLIST -> "keep a checklist on your board" BuddyActionType.AMEND_CHECKLIST -> "add to a checklist on your board" BuddyActionType.PLACE_NOTE -> "keep a note on your board" diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index 0c340e89..ec56775a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -111,7 +111,8 @@ class BuddyPathActions( BuddyActionType.COMPLETE_TASK -> proposeCompleteTask(call, type, userId) BuddyActionType.ANSWER_QUESTION -> proposeAnswer(call, type, userId) BuddyActionType.REQUEST_SKIP -> proposeSkip(call, type, userId) - else -> proposeAddPathStep(call, type, userId) + BuddyActionType.ADD_PATH_STEP -> proposeAddPathStep(call, type, userId) + else -> error("${type.toolName} is not a path action") } /** Runs a confirmed path action. Each underlying `/me/...` operation owns its own rules. */ @@ -125,7 +126,8 @@ class BuddyPathActions( BuddyActionType.COMPLETE_TASK -> completeTask(authId, request.onboardingTaskId) BuddyActionType.ANSWER_QUESTION -> answerQuestion(authId, request.questionId, request.answer) BuddyActionType.REQUEST_SKIP -> requestSkip(authId, request.stepId, request.reason) - else -> addPathStep(authId, request) + BuddyActionType.ADD_PATH_STEP -> addPathStep(authId, request) + else -> error("${type.toolName} is not a path action") } /** @@ -168,7 +170,7 @@ class BuddyPathActions( // Finishing a step withdraws a skip request still waiting on the PM. Allowed -- somebody who // did the step anyway should be able to close it -- but never without saying so, on the // button and to the mentor, because nothing else would tell them the request is gone. - val pendingSkip = step.skip != null && step.skip.accepted == null + val pendingSkip = step.hasPendingSkip() val withdraws = if (pendingSkip) { " They asked their PM to skip this step and nobody has decided yet: finishing it withdraws " + "that request, so say so plainly before they click." @@ -297,7 +299,7 @@ class BuddyPathActions( "“${step.title}” is already done, so there is nothing to skip." step.status == StepStatus.SKIPPED -> "“${step.title}” is already skipped — their PM accepted it." - previous != null && previous.accepted == null -> + step.hasPendingSkip() -> "They already asked to skip “${step.title}” and their PM has not decided yet. A second " + "request cannot be sent; they can change the reason on the step's own page " + "(${BuddyPathTools.STEP_PAGE_LINK}${step.id})." @@ -402,7 +404,7 @@ class BuddyPathActions( } val entry = if (placement.entryInferred) { " You passed no waits_on, so it opens after what the path says it should (the button " + - "names it) -- if that is not where they meant, offer it again with waits_on." + "names it) — if that is not where they meant, offer it again with waits_on." } else { "" } @@ -486,7 +488,7 @@ class BuddyPathActions( // The task route does not check locks; the page does, by never opening a locked step. if (buddyPathTools.findStep(userId, task.stepId)?.locked == true) { return refused( - "The step “${task.title}” belongs to is locked, so nothing on its checklist can be " + + "The step that “${task.title}” belongs to is locked, so nothing on its checklist can be " + "ticked yet. Say what the step is waiting on instead.", ) } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt index c709953e..22d0d092 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathTools.kt @@ -30,7 +30,7 @@ import java.util.UUID * - The **blueprint** is the PM's. The buddy never touches it; a mentor that could rewrite the * curriculum is a mentor whose team stops trusting the curriculum. * - The **hire's copy** is the hire's, and the buddy may propose changes to it -- see - * [BuddyActionService], where every one of them waits for a button. + * [BuddyPathActions], where every one of them waits for a button. * - **Reading really is free of consequence here**, unlike [BuddyBoardTools.execute]'s board read, * which brings a board's baseline cards up to date by looking. Nothing is created by asking. * @@ -71,7 +71,7 @@ class BuddyPathTools( /** * Whether this hire has a path at all. * - * Exposed so that [BuddyActionService] gates its path actions on the same question this gates + * Exposed so that [BuddyPathActions] gates its path actions on the same question this gates * its read tool on, answered by the same call. Two components deciding separately whether a path * exists is how a mentor ends up holding an action for a plan its read tool says is not there. */ @@ -111,7 +111,7 @@ class BuddyPathTools( appendLine() appendCurrentPhase(current, phases.indexOf(current), phases, lastWrongAnswers(userId, current)) appendReadyToClose(current, phases, checklists) - appendCurrentTasks(stepTheyAreOn(current), checklists) + appendCurrentTasks(nextStepIn(current), checklists) appendNextItem(current, readyToClose(current, checklists).firstOrNull()) appendAhead(phases, current) appendEmptyPhases(path) @@ -230,21 +230,6 @@ class BuddyPathTools( ) } - /** - * The step whose checklist is worth putting in front of the mentor: the one they have started, or - * else the first one they could start. - * - * Started wins over next, because a hire with something open is talking about that and not about - * what comes after it. Null when the phase has neither, which is when a checklist would be a - * heading over nothing. - */ - private fun stepTheyAreOn(phase: GetOnboardingPhaseForUserResponse): GetOnboardingStepsResponse? { - if (phase.locked) return null - val ordered = phase.steps.sortedBy { it.position }.filterNot { it.locked } - return ordered.firstOrNull { it.status == StepStatus.IN_PROGRESS } - ?: ordered.firstOrNull { it.status == StepStatus.WAITING } - } - /** * The path in one or two sentences, for the opening greeting to ground itself in, or null when * the hire has no path. @@ -307,7 +292,7 @@ class BuddyPathTools( * a proposal can never be aimed at somebody else's onboarding. [findStep] and [findQuestion] * are the same idea for the other two kinds of node. * - * Used by [BuddyActionService] to check a proposal *before* the hire sees a button, and to put + * Used by [BuddyPathActions] to check a proposal *before* the hire sees a button, and to put * the real title on it. A button that names the thing it will change is the last chance anybody * has to notice the mentor meant a different step. */ diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt index dd729107..90d98b59 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStanding.kt @@ -4,6 +4,7 @@ import com.sprintstart.sprintstartbackend.onboarding.external.enums.QuestionStat import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus import com.sprintstart.sprintstartbackend.onboarding.model.response.phase.GetOnboardingPhaseForUserResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.step.GetOnboardingStepsResponse +import java.util.UUID /** * Where the hire stands on a path whose phases run side by side, and what comes next in a phase. @@ -76,30 +77,41 @@ internal data class NextPathItem( * while the page pointed at the question in front of it. */ internal fun nextItemIn(phase: GetOnboardingPhaseForUserResponse): NextPathItem? { + val id = nextIdIn(phase) ?: return null + phase.steps.firstOrNull { it.id == id }?.let { return stepItem(it) } + val question = phase.questions.first { it.id == id } + return NextPathItem( + plain = "the question ${BuddyPathTools.quoted(question.question)}", + withIds = "the question ${BuddyPathTools.quoted(question.question)} " + + "[question_id: ${question.id}] [link: ${BuddyPathTools.QUESTION_LINK}${question.id}]", + ) +} + +/** + * The step whose checklist the mentor is shown: the step [nextItemIn] names, or null when that is a + * question or nothing. Asked of the same rule, so the checklist in the tool result is always the + * checklist of the thing it calls next. + */ +internal fun nextStepIn(phase: GetOnboardingPhaseForUserResponse): GetOnboardingStepsResponse? = + nextIdIn(phase)?.let { id -> phase.steps.firstOrNull { it.id == id } } + +/** The id behind [nextItemIn]: a step or a question of [phase], or null. */ +private fun nextIdIn(phase: GetOnboardingPhaseForUserResponse): UUID? { if (phase.locked || !phase.isOpen()) return null val steps = phase.steps.sortedBy { it.position } val questions = phase.questions.sortedBy { it.position } - steps.firstOrNull { it.status == StepStatus.IN_PROGRESS }?.let { return stepItem(it) } + steps.firstOrNull { it.status == StepStatus.IN_PROGRESS }?.let { return it.id } val stepsById = steps.associateBy { it.id } val questionsById = questions.associateBy { it.id } val order = PhaseReadingOrder.of( steps.map { it.id to it.blockerIds } + questions.map { it.id to it.blockerIds }, ) - for (id in order) { - stepsById[id]?.takeIf { it.isReadyToStart() }?.let { return stepItem(it) } - questionsById[id] - ?.takeIf { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } - ?.let { question -> - return NextPathItem( - plain = "the question ${BuddyPathTools.quoted(question.question)}", - withIds = "the question ${BuddyPathTools.quoted(question.question)} " + - "[question_id: ${question.id}] [link: ${BuddyPathTools.QUESTION_LINK}${question.id}]", - ) - } + return order.firstOrNull { id -> + stepsById[id]?.isReadyToStart() == true || + questionsById[id]?.let { it.status == QuestionStatus.OPEN || it.status == QuestionStatus.RETRY } == true } - return null } private fun stepItem(step: GetOnboardingStepsResponse) = NextPathItem( diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt index 2385c631..4f2090fd 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/PathStepPlacement.kt @@ -69,7 +69,7 @@ internal class PathStepPlacement( ).joinToString(", ") } - /** Whether any item in [unlocks] is a step that is already started, which placing this would re-lock. */ + /** The titles of the steps in [unlocks] that are already started, which placing this would lock again. */ fun relocksStarted(): List = unlocks.mapNotNull { id -> stepsById[id]?.takeIf { it.status == StepStatus.IN_PROGRESS }?.let { titleOf(id) } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt index a9435973..e5f7c7a6 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathToolsTest.kt @@ -339,6 +339,32 @@ class BuddyPathToolsTest { assertThat(text).contains("finished with lines still open") } + /** + * The graph from `PhaseReadingOrderTest`, where the page reads `s2` before `s1`: the checklist + * shown is the one of the step named next, not of the first open step by position. + */ + @Test + fun `the checklist shown is the one of the step named next`() { + val s1 = step("Read the handbook", StepStatus.WAITING) + val s2 = step("Meet the team", StepStatus.WAITING) + val s3 = step("Pair with a teammate", StepStatus.WAITING, locked = true, blockers = setOf(s2.id)) + val q1 = question("Who owns deploys?", QuestionStatus.OPEN) + val s4 = step("Ship a fix", StepStatus.WAITING, locked = true, blockers = setOf(s1.id, q1.id)) + val q2 = question("Who reviews?", QuestionStatus.LOCKED).copy(blockerIds = setOf(s3.id)) + every { onboardingPathService.findPathForUserId(userId) } returns + path(phase(0, "Team", steps = listOf(s1, s2, s3, s4), questions = listOf(q1, q2))) + every { onboardingTaskService.getOnboardingTasksByStepId(s1.id) } returns + listOf(task("Open the handbook", finished = false)) + every { onboardingTaskService.getOnboardingTasksByStepId(s2.id) } returns + listOf(task("Say hello", finished = false)) + + val text = tools.execute(userId) + + assertThat(text).contains("The next thing waiting for them: the step “Meet the team”") + assertThat(text).contains("The checklist of “Meet the team”, the step they are on:") + assertThat(text).doesNotContain("The checklist of “Read the handbook”") + } + @Test fun `a step whose checklist is done but that is still open is named, with what it holds up`() { // Every line ticked, the step's own button never pressed: whatever waits on it stays locked From 00203027c0cdc0b36efb610b5baf07a193d94b77 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 14:11:05 +0200 Subject: [PATCH 22/22] Let an explicit hire request be reason enough for buddy actions flag_to_pm was described only as a last resort for questions the docs do not cover, so the mentor refused a hire's explicit "flag this to my PM" as not being a PM matter. Its description now names the hire asking as its own case, and accepts a problem or feedback as well as a question. add_path_step, place_checklist and place_note had the same shape (only the mentor's judgement could trigger them) and now also say that the hire asking is enough. Co-Authored-By: Claude Opus 5.5 --- .../onboarding/service/BuddyActionService.kt | 18 +++++++++++++----- .../service/BuddyBoardWriteActions.kt | 6 ++++-- .../onboarding/service/BuddyPathActions.kt | 3 ++- .../service/BuddyActionServiceTest.kt | 10 ++++++++++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 3d18d8b1..02f7abf1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -724,16 +724,24 @@ class BuddyActionService( val FLAG_TO_PM_SPEC = BuddyToolSpecDto( name = BuddyActionType.FLAG_TO_PM.toolName, - description = "Offer to escalate the hire's question to their project's PM, when neither the docs " + - "nor the canonical answers cover it. This does NOT send anything — it shows the hire a confirm " + - "button, and only they can send it. Provide the question to ask, phrased clearly, in `question`. " + - "Use this as the last resort when you genuinely cannot ground an answer.", + description = "Offer to pass something from the hire to their project's PM. Two cases: " + + "(1) the hire ASKS you to flag, raise or pass something to their PM — a question, a problem, a " + + "blocker, feedback on their path. Their asking is the reason: offer it straight away, and never " + + "decide for them that it is not a PM matter or that the docs answer it first. " + + "(2) Neither the docs nor the canonical answers cover a question, as the last resort when you " + + "genuinely cannot ground an answer. " + + "This does NOT send anything — it shows the hire a confirm button, and only they can send it. " + + "Put what should reach the PM in `question`, phrased clearly and in the hire's sense.", parameters = buildJsonObject { put("type", "object") putJsonObject("properties") { putJsonObject("question") { put("type", "string") - put("description", "The question to send to the PM, phrased clearly for a person to answer.") + put( + "description", + "What to send to the PM — a question, or what the hire wants them to know — phrased " + + "clearly for a person to answer.", + ) } } putJsonArray("required") { add("question") } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActions.kt index 71285480..57aa0041 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActions.kt @@ -493,7 +493,8 @@ class BuddyBoardWriteActions( description = "Offer to keep a list you have just written as a checklist card on the " + "hire's board. Use it right after you have answered 'how do I start' or 'what do " + "I do next' with steps — the conversation is not replayed, so a list they only " + - "read here is a list they will have to ask for again tomorrow. " + + "read here is a list they will have to ask for again tomorrow — and whenever they ask " + + "you to put a list on their board. " + "ONE ITEM PER THING THEY DO, not one per line you wrote. A card is a flat list of " + "things to tick off, so an answer with headed sections and bullets under them " + "becomes one item per section, with the detail folded into that item's own words " + @@ -648,7 +649,8 @@ class BuddyBoardWriteActions( description = "Offer to keep an explanation you have just given as a note on the " + "hire's board. Use it sparingly and only for something that will still be true " + "and still be needed next week — how a part of this system works, a convention " + - "the team holds to. Not for an answer about right now, and not for steps: those " + + "the team holds to — or whenever the hire asks you to keep something as a note; their " + + "asking outweighs your sense of what is worth keeping. Not for steps: those " + "are place_checklist. Pass the explanation in your own words from the reply, " + "shortened to what is worth keeping. Every reply already carries a button that " + "keeps the whole answer, so only offer this when a card is better than that. This " + diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt index ec56775a..057d6cb7 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyPathActions.kt @@ -903,7 +903,8 @@ class BuddyPathActions( "knowledge question they got wrong, once you have gone through the material, when " + "what they missed is bigger than one explanation — then one short refresher step in " + "that question's phase, saying what to revisit and where, and never the answer. " + - "Offer it; do not add one after every wrong answer. Give a title " + + "Offer it; do not add one after every wrong answer. And whenever the hire ASKS you to " + + "add a step: it is their copy, so their asking is enough — offer it, do not argue. Give a title " + "of a few words and a description saying what doing it involves. Do not offer a " + "step for something already on their path, do not add several at once, and do not " + "add one just to have added something.\n" + diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt index d3d0739e..15e03b13 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt @@ -121,6 +121,16 @@ class BuddyActionServiceTest { assertThat(service.actionSpecs(userId).map { it.name }).contains("complete_step") } + @Test + fun `tells the mentor that a hire asking to flag something is reason enough`() { + // "Last resort" alone had the mentor refuse an explicit "flag this to my PM". + every { buddyPathActions.specs(userId) } returns emptyList() + val flag = service.actionSpecs(userId).single { it.name == "flag_to_pm" } + + assertThat(flag.description).contains("the hire ASKS you to flag") + assertThat(flag.description).contains("never decide for them that it is not a PM matter") + } + @Test fun `recognises action tools and rejects read tools`() { assertThat(service.isAction("open_orientation")).isTrue()